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:
Hank 2026-07-16 22:11:08 +00:00
parent 267957d333
commit 1ba3f9ad1c
5 changed files with 101 additions and 13 deletions

View file

@ -69,9 +69,15 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// Ticket-voucher entitlement: how many free tickets a donor has earned from // Ticket-voucher entitlement: how many FREE tickets a donor has left. This is
// giving on/after VOUCHER_SINCE. Same secret/CORS/rate-limit as above. // the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the
// Returns only the count (0/1/2) — no dollar amounts. // 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( app.get(
"/api/public/ticket-vouchers", "/api/public/ticket-vouchers",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, { 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 { email } = (req.query ?? {}) as { email?: string };
const addr = String(email ?? "").trim(); const addr = String(email ?? "").trim();
if (!addr) return { vouchers: 0 }; if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
try { try {
const cutoff = new Date(cfg.VOUCHER_SINCE); const cutoff = new Date(cfg.VOUCHER_SINCE);
const { amount } = await app.ctx.donors.amountSince(addr, cutoff); const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
const vouchers = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0; const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
return { vouchers }; const used = await app.ctx.nocodb.vouchersUsedByEmail(addr);
const remaining = Math.max(0, entitled - used);
return { vouchers: remaining, entitled, used, remaining };
} catch { } catch {
// Fail closed — grant no vouchers rather than error. // Fail closed — grant no vouchers rather than error.
return { vouchers: 0 }; return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
} }
}, },
); );

View file

@ -76,6 +76,22 @@ export class NocoDBClient {
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit); 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> { async create(fields: Record<string, unknown>): Promise<NocoRecord> {
const body = await this.request(this.recordsUrl, { const body = await this.request(this.recordsUrl, {
method: "POST", method: "POST",

View file

@ -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> { async create(fields: Record<string, unknown>): Promise<NocoRecord> {
await this.delay(); await this.delay();
const rec = { Id: this.nextId++, ...fields } as NocoRecord; const rec = { Id: this.nextId++, ...fields } as NocoRecord;

View 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
});
});

View file

@ -14,16 +14,32 @@ ticketing backend.
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email> 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 ```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`). - `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
- `email` = the donor's email (URL-encoded). - `email` = the donor's email (URL-encoded).
- Rate-limited (30 requests / minute / IP) and CORS-restricted to - 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 > 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 > 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>" curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
# >= $1000 since cutoff -> {"vouchers":2} # entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
# >= $400 since cutoff -> {"vouchers":1} # after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
# otherwise -> {"vouchers":0}
``` ```
Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the