Sajjad Mahessar Good Merch Group Free audit
~/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.

Type
Partner scheme
Category
Systems
Built with
PostgreSQL / Supabase · RLS + SECURITY DEFINER · Single-file HTML · Python (ReportLab)
Project
15 of 37
The voucher print sheet, ten cards to an A4 page with cut guides
TWO STAFF · SAME CARD · SAME INSTANT UPDATE ... SET redeemed_at = now() WHERE redeemed_at IS NULL 1 ROWPOURED 0 ROWSUSED POSTGRES PICKS THE WINNER UNDER ROW LOCK · NO READ-THEN-WRITE GAP
The check and the write are one statement, evaluated under row lock

Making it bulletproof

  • Redemption is a single conditional update, not a read-then-write. The redeemed_at IS NULL condition 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 gets ALREADY 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_at without 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_at being 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 separate void flag, 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.
Telephony

Automated call routing

Keypad routing with voicemail to email

Twilio StudioFunctionsTwiMLSendGrid
Apple Wallet

Apple Wallet business card

A signed business card pass

PassKitOpenSSL / CMSPython (segno)Netlify
No charge

I’ll find the money before you pay me anything.

Send me your software subscriptions and I’ll come back with what’s replaceable and what it’s worth — free, and yours to keep either way. How it works →

Contact

Want the same done to your cost base?

Open to operations, automation and technical operations roles — and to talking with anyone running a multi-site business who suspects they're paying for software they could own instead.