Ticket vouchers now return remaining and decrement on use
The ticket-vouchers lookup previously returned the tier entitlement every time, so a donor could keep claiming free tickets by re- submitting the form. It now subtracts vouchers already consumed: remaining = entitled - used where `used` is the sum of the Vouchers column across that donor's prior ticket orders (each checkout stores what it applied). Response gains entitled/used/remaining; `vouchers` is now the remaining count the form should grant. Consumption is implicit — no counter to keep in sync — and resets by zeroing/deleting the Vouchers value on the order row in NocoDB. - nocodb: findByEmail + vouchersUsedByEmail (case-insensitive). - 8 new tests (36 total). Doc updated with the new response + reset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
267957d333
commit
1ba3f9ad1c
5 changed files with 101 additions and 13 deletions
|
|
@ -69,9 +69,15 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
|
|||
},
|
||||
);
|
||||
|
||||
// Ticket-voucher entitlement: how many free tickets a donor has earned from
|
||||
// giving on/after VOUCHER_SINCE. Same secret/CORS/rate-limit as above.
|
||||
// Returns only the count (0/1/2) — no dollar amounts.
|
||||
// Ticket-voucher entitlement: how many FREE tickets a donor has left. This is
|
||||
// the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the
|
||||
// vouchers already consumed by their prior ticket orders (each order stores
|
||||
// how many it used), so a donor can't keep claiming free tickets by
|
||||
// re-submitting the form. `vouchers` is the remaining count the form should
|
||||
// grant; `entitled`/`used`/`remaining` are the breakdown. No dollar amounts.
|
||||
//
|
||||
// To reset for testing: zero out (or delete) the "Vouchers" value on that
|
||||
// donor's ticket order row(s) in NocoDB — `used` drops and `remaining` rises.
|
||||
app.get(
|
||||
"/api/public/ticket-vouchers",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
|
|
@ -85,16 +91,18 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
|
|||
}
|
||||
const { email } = (req.query ?? {}) as { email?: string };
|
||||
const addr = String(email ?? "").trim();
|
||||
if (!addr) return { vouchers: 0 };
|
||||
if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
|
||||
|
||||
try {
|
||||
const cutoff = new Date(cfg.VOUCHER_SINCE);
|
||||
const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
|
||||
const vouchers = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
|
||||
return { vouchers };
|
||||
const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
|
||||
const used = await app.ctx.nocodb.vouchersUsedByEmail(addr);
|
||||
const remaining = Math.max(0, entitled - used);
|
||||
return { vouchers: remaining, entitled, used, remaining };
|
||||
} catch {
|
||||
// Fail closed — grant no vouchers rather than error.
|
||||
return { vouchers: 0 };
|
||||
return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -76,6 +76,22 @@ export class NocoDBClient {
|
|||
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
|
||||
}
|
||||
|
||||
/** Every order for an exact email (case-insensitive). */
|
||||
async findByEmail(email: string, limit = 1000): Promise<NocoRecord[]> {
|
||||
const rows = await this.list(`(${COL.email},eq,${escapeValue(email)})`, limit);
|
||||
// Belt-and-suspenders: some NocoDB backends do a case-sensitive eq, so
|
||||
// narrow/confirm against a lowercased compare in JS.
|
||||
const target = email.trim().toLowerCase();
|
||||
const exact = rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
|
||||
return exact.length ? exact : rows;
|
||||
}
|
||||
|
||||
/** Sum of ticket vouchers a donor has already consumed across their orders. */
|
||||
async vouchersUsedByEmail(email: string): Promise<number> {
|
||||
const rows = await this.findByEmail(email);
|
||||
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
|
||||
}
|
||||
|
||||
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||
const body = await this.request(this.recordsUrl, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ export class FakeNocoDB {
|
|||
);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<NocoRecord[]> {
|
||||
await this.delay();
|
||||
const target = email.trim().toLowerCase();
|
||||
return this.rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
|
||||
}
|
||||
|
||||
async vouchersUsedByEmail(email: string): Promise<number> {
|
||||
const rows = await this.findByEmail(email);
|
||||
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
|
||||
}
|
||||
|
||||
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||
await this.delay();
|
||||
const rec = { Id: this.nextId++, ...fields } as NocoRecord;
|
||||
|
|
|
|||
38
backend/src/test/vouchers.test.ts
Normal file
38
backend/src/test/vouchers.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { FakeNocoDB } from "./fakeNocodb.js";
|
||||
import { COL } from "../fields.js";
|
||||
|
||||
/** Mirror the ticket-vouchers endpoint's remaining math. */
|
||||
function remaining(entitled: number, used: number): number {
|
||||
return Math.max(0, entitled - used);
|
||||
}
|
||||
|
||||
describe("voucher consumption", () => {
|
||||
it("sums vouchers used across a donor's orders", async () => {
|
||||
const db = new FakeNocoDB(0);
|
||||
await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 2 });
|
||||
await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 1 });
|
||||
await db.create({ [COL.email]: "someone-else@example.com", [COL.vouchers]: 2 });
|
||||
await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 0 }); // non-voucher order
|
||||
expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(3);
|
||||
});
|
||||
|
||||
it("matches email case-insensitively", async () => {
|
||||
const db = new FakeNocoDB(0);
|
||||
await db.create({ [COL.email]: "Donor@Example.com", [COL.vouchers]: 2 });
|
||||
expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 used for a donor with no orders", async () => {
|
||||
const db = new FakeNocoDB(0);
|
||||
expect(await db.vouchersUsedByEmail("nobody@example.com")).toBe(0);
|
||||
});
|
||||
|
||||
it("remaining = entitled - used, floored at 0", () => {
|
||||
expect(remaining(2, 0)).toBe(2); // fresh 2-voucher donor
|
||||
expect(remaining(2, 1)).toBe(1); // used one
|
||||
expect(remaining(2, 2)).toBe(0); // used both — no more free tickets
|
||||
expect(remaining(1, 2)).toBe(0); // over-consumed (edge) never goes negative
|
||||
expect(remaining(0, 0)).toBe(0); // non-donor
|
||||
});
|
||||
});
|
||||
|
|
@ -14,16 +14,32 @@ ticketing backend.
|
|||
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
|
||||
```
|
||||
|
||||
Returns only the count — never names or dollar amounts:
|
||||
Returns the **remaining** free-ticket count — never names or dollar amounts:
|
||||
|
||||
```json
|
||||
{ "vouchers": 0 } // or 1, or 2
|
||||
{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 }
|
||||
```
|
||||
|
||||
- `vouchers` / `remaining` — how many free tickets are **still available** (this
|
||||
is what the form should grant). Use `vouchers`; `remaining` is an alias.
|
||||
- `entitled` — the tier entitlement earned from giving (0/1/2).
|
||||
- `used` — vouchers already consumed by this donor's prior ticket orders.
|
||||
- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
|
||||
- `email` = the donor's email (URL-encoded).
|
||||
- Rate-limited (30 requests / minute / IP) and CORS-restricted to
|
||||
`PUBLIC_LOOKUP_ORIGIN` (default `https://tickets.beartariacampgrounds.com`).
|
||||
`PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`).
|
||||
|
||||
### Vouchers decrement as they're used
|
||||
|
||||
`remaining = entitled − used`, where `used` is the sum of the **Vouchers**
|
||||
column across every ticket order placed with that email. Each checkout stores
|
||||
the vouchers it applied, so the next lookup returns fewer — a donor can't keep
|
||||
claiming free tickets by re-submitting the form. Once `used ≥ entitled`,
|
||||
`vouchers` is `0`.
|
||||
|
||||
**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers**
|
||||
value on that donor's ticket order row(s). `used` drops and `remaining` rises on
|
||||
the next lookup — no redeploy needed.
|
||||
|
||||
> The secret is visible in page source, so treat it as **deterrence, not
|
||||
> security** — it only gates a 0/1/2 count. Rotate it by changing
|
||||
|
|
@ -107,9 +123,8 @@ field `free_tickets` you can use for conditional logic or to cap a quantity.
|
|||
|
||||
```
|
||||
curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
|
||||
# >= $1000 since cutoff -> {"vouchers":2}
|
||||
# >= $400 since cutoff -> {"vouchers":1}
|
||||
# otherwise -> {"vouchers":0}
|
||||
# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
|
||||
# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
|
||||
```
|
||||
|
||||
Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue