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
|
// 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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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>
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue