~/sajjad $ cat projects/voucher-redemption
← cd ../projects
Systems
Partner voucher redemption
Live since Aug 2026150 single-use codes
A single-use discount voucher scheme run with a canal boat hire operator moored beside one of the sites. They hand 150 printed cards to their customers; we never deal with those customers directly. Each code works exactly once, ever — and that's enforced in the database, not by the paper.
Making it bulletproof
- Redemption is a single conditional update, not a read-then-write. The
redeemed_at IS NULLcondition is evaluated by Postgres as part of the write, under row lock. Two staff scanning the same card in the same instant cannot both succeed — one gets the row, the other getsALREADY USED. A check-then-write would have left a race window open on exactly the busy Saturday it matters. - There is no insert, update or delete policy on the table at all. Not even a signed-in member of staff can write to it directly. Every change goes through one security-definer function, which is what makes the audit trail trustworthy: no path exists that sets
redeemed_atwithout also setting who did it. - The anonymous key baked into the page has been revoked from the table entirely, so a guessed code hits a wall before it reaches any data. Lookups return zero rows for a code that doesn't exist rather than an error, so probing reveals nothing — and only after getting past a login anyway.
redeemed_atbeing null is the unused state. No separate status column, so a voucher marked used with no timestamp is not a state the schema can represent. Cancellation is a separatevoidflag, so a card lost in the post doesn't look like a customer used it.- A unique index on
upper(code)makes uniqueness case-insensitive, so two codes differing only in case can never coexist.
Total exposure is capped at 150 discounted food bills. Even in the worst case — the entire code list leaking publicly — the scheme cannot cost more than what was already agreed to give away.
Decisions in the room, not the code
- The staff device scans the customer's card, never the reverse. The single most important rule in the design — a green screen on a customer's own phone proves nothing and is a screenshot waiting to happen. The guest-facing page deliberately reveals nothing about whether a voucher is still valid.
- Two-tap redeem, because the action is irreversible and misfires behind a busy bar are inevitable. And no un-redeem in the interface at all — reversing one requires database access, so it has to come to me. Staff can't quietly undo a mistake.
- The code alphabet omits I, O, 0 and 1 — the characters people misread off a scuffed card — leaving 32 characters across 8 positions, around 1.1 trillion combinations against 150 live codes. The format is chunked so it can be read down the phone without losing your place, and the input auto-formats as it's typed.
- Cards generated at business-card size with error-correction-level-H QR codes, so a card can take a beer ring and still scan. The scanner regex-matches the code anywhere in the payload, so it works whether the QR carries a full URL or a bare code.
- The card is taken off the guest and kept. Not handed back, not photographed — a card in the till drawer cannot be passed to the next table, and a photo or screenshot is refused outright.
- Staff are told to read the screen, not the card: the screen states 15% or 20%, and it is the screen that decides. Redeem is tapped only once the guest has ordered, because it cannot be undone.
- Deliberately not built: no EPOS integration (weeks of work to save four seconds per transaction), no email delivery (a card can be taken off the guest at the point of use, an email can't), no minimum spend (a rule staff have to police, for no real protection).
What went wrong
- A drag-and-drop deploy created a new site rather than adding to the existing one. Left that way on purpose — the other project runs live Stripe card payments, and keeping the voucher page out of that folder means never redeploying a payments site to change voucher wording.
- The custom domain 404'd on first visit. The single-file deploy had been promoted to
index.html, so the page had been live at the domain root the whole time. - A certificate provisioning failure that looked like a fault but was expected and self-correcting — the host can't validate a domain that isn't pointing at it yet.
- The database editor's 100-row default silently truncated the code export, losing an entire batch of 50 with no error shown. Caught by counting the output against what was seeded, then worked around by exporting each batch separately.
Written up with its open items rather than signed off clean: a shared venue login identifies the site but not the individual, and the two batches are visually indistinguishable, so they have to stay physically separated or be sorted one lookup at a time.