All posts

Engineering7 min read

Why a stamp is a ledger entry, not a number

The obvious way to build a stamp card is a counter you increment. We built an append-only ledger instead, and every undo, correction and analytics figure follows from that choice.


The obvious data model for a stamp card is a number. Each customer has a stamps column; a stamp adds one, a reward resets it to zero. It is what a spreadsheet would do, and it is what the paper card does.

We did not build it that way, and the reason is not academic. A counter cannot answer the questions an owner actually asks. Who gave this customer twelve stamps on a Tuesday? Was that reward earned or was the balance adjusted? What happened to the stamp that was there an hour ago? With a counter, the answer to all three is a shrug.

Every stamp is a row

In Stamposa, a stamp is a row in a ledger that is only ever appended to. Each row carries a signed amount, who issued it, why, and when. A normal stamp is +1 from a staff member. A reward completion is recorded against the specific row that filled the card. A customer’s balance is not stored anywhere; it is the sum of their rows.

That one decision means the ledger is the truth and everything else is a view of it. The number on the customer’s card, the count on the dashboard, and the analytics for last month are all derived from the same rows, so they cannot disagree with each other.

Undo does not delete

When a staff member undoes a stamp within their 60-second window, nothing is removed. The original row is marked as undone, and a matching −1 reversal is appended next to it. The balance goes back to where it was, and the record now shows that a stamp was added at 10:41 and reversed at 10:42 by the same person.

The rules around undo fall out of the ledger naturally:

  • Only the newest standing row can be undone. Reversals and already-undone rows are skipped.
  • Staff may undo their own last stamp within 60 seconds. Managers may undo any stamp within 15 minutes.
  • If the stamp being undone was the one that completed a card, the voucher it minted is voided. If that voucher has already been handed over, the undo is refused, because you cannot un-give a coffee.

The last rule is the kind of thing a counter model gets wrong silently. Decrement the number and the customer has nine stamps and a free coffee. The ledger makes the conflict visible, so the software can say no.

Corrections are rows too

Owners sometimes need to fix a balance: a customer migrating from a paper card, or a stamp that was missed last week. That is an adjustment row, with a signed amount and a reason the owner has to type. It appears in the ledger exactly like a stamp does, tagged as an adjustment, so nobody later mistakes it for a visit.

Adjustments also do not count towards the daily stamp cap. The cap is there to stop over-stamping at the counter, so it only looks at positive rows from staff that have not been undone in the last 24 hours. An owner correcting a balance is not the thing the cap exists to prevent.

Rewards are vouchers

When a card fills, the same transaction that records the completing stamp also mints a voucher with a short code and a snapshot of the reward text at that moment. If the owner later changes the reward from a coffee to a pastry, customers who already earned a coffee still get a coffee.

A voucher is waiting until the counter hands it over, at which point it is redeemed with the time and the staff member recorded. A completion undo flips it to void. These transitions are guarded so two staff members confirming the same hand-over at the same moment cannot both succeed.

Analytics that survive corrections

Because undo is a reversal row rather than a deletion, analytics simply sum the signed amounts over the period. A stamp that was added and undone contributes +1 and −1 and nets to zero. The staff leaderboard works the same way, so a staff member cannot inflate their count by stamping and undoing.

The same is true for the CSV export. What the owner downloads is the ledger, with the reversal rows included, not a cleaned-up summary. If they want to audit a month, the data to do it is in the file.

Tenancy, briefly

One more invariant worth stating: which shop a row belongs to is always taken from the signed-in staff member or owner, never from anything the browser sends. A request that reaches for another shop’s customer gets a not-found, not a permission error, because we would rather not confirm the customer exists at all.

Try it with your own card

Set up your program, print the QR, and stamp your first customer today.