Case study · Backend systems

Gift cards that can't
be spent twice.

A single-use e-gift-card backend for a hospitality group, where the same code can be presented at any of several independent front desks — and must be honoured exactly once.

Role
Sole backend engineer — domain model, API, deployment
Context
Client work at Pier 5 Studios
Stack
Node · Express · Prisma · PostgreSQL · PM2 · nginx
Status
Shipped to production

The problem

The client wanted trackable gift cards: issued now, monetised later, redeemable at any of their properties. There was no existing system — cards were an idea in a meeting, and most of the product questions were still open, including who would ultimately be allowed to create one.

What made it more than CRUD is that a gift card is a promise about entitlement, redeemable across sites that don't coordinate with each other, carrying real financial value, handled by staff under time pressure. The interesting work was in constraining it, not building it.

Approach

01

Treat the entitlement set as a contract, not configuration

The obvious design is an editable table of card types behind an admin screen. I deliberately didn't build one — entitlements are fixed server-side. An editable set means someone can invent a new one at 2am, and the front desk inherits a card whose meaning no process covers.

What it bought — every card in circulation maps to a documented, agreed entitlement. Support load from "what does this card actually include" stays at zero. Cost — new card types need a deploy.

02

Push the correctness guarantee down to the database

Redemption is the failure mode with money attached, so it doesn't live in application logic. Rather than read the card, check its state, then write, the whole operation is one conditional write that can only succeed against an unredeemed row.

What it bought — two desks scanning the same code in the same second resolve to exactly one redemption and one clean rejection, with no locking layer, no coordination between sites, and no reconciliation job.

03

Design the code format around how it actually travels

Codes get read aloud down a phone line and typed by hand at a desk, so they're generated from an alphabet with every visually ambiguous character removed. This is a small decision that only surfaces if you think about the humans in the loop rather than the entropy budget.

What it bought — an entire class of support call that never happens.

04

Split the roles that create value from the roles that approve it

Cards issued for free are functionally a way to mint money. Letting one role both create and finalise a card is an internal-fraud shape, so issuance and approval sit with different roles.

What it bought — every card carries two names in its audit trail, and the control existed before it was ever needed rather than after an incident.

05

Isolate the unresolved decisions so they couldn't block shipping

Two things were genuinely undecided: who creates cards, and whether email delivery would be in scope. Both were designed as edges rather than assumptions — issuance doesn't care what kind of client called it, and delivery is optional, so a card is always created and always shareable even when mail isn't configured.

What it bought — the backend shipped while the product questions were still open. Cost — abuse controls for public issuance are deferred debt, tracked as such.

06

Match the sibling service's conventions, share none of its runtime

The group already ran a related backend maintained by the same small team. This one mirrors its structure, auth model and operational patterns closely, while running as an independent process against its own database.

What it bought — a maintainer moves between the two without re-learning anything, and neither service can take the other down.

How it fits together

Guest share link / email Front desk JWT · issue / redeem nginx TLS vhost Gift-card API Express · PM2 fail-closed auth PostgreSQL localhost only SMTP optional conditional write
One process, one database, one vhost — fully independent of the sibling service sharing the host.

What shipped

A production API covering the full lifecycle — staff authentication, issuance with an approval step, a public card-view endpoint powering the shareable link, search and filtering for front-desk staff, and single-use redemption with a complete audit trail of who issued a card, who redeemed it, and when.

Node.js Express Prisma PostgreSQL JWT PM2 nginx Nodemailer

What I'd do differently

Rate limiting is held in process memory. That's correct for a single instance and quietly wrong the moment this scales horizontally, since each instance would keep its own counters. It was the right trade for shipping, and it's the first thing I'd move to a shared store.

The decision I'd defend hardest is the one not to build an admin screen for editing card types. Every reviewer asks for it. It would have made the system worse.