Part 5 of 7 · Gift card ledger series ~7 min read

How a card expires and reconciles

Two slow, unglamorous jobs keep a gift card ledger honest over time. The first is expiry: cards that have sat untouched past the policy window need retiring, and the leftover balance booked as breakage, cleanly and on a schedule rather than by hand. The second is reconciliation: at month-end the business needs one trustworthy number for how much it still owes in live cards. This post walks through the nightly expiry sweep and the monthly reconciliation that re-derives every balance from the immutable ledger and proves the books still add up to the cent.

Key takeaways

  • A nightly EventBridge schedule sweeps for cards dormant past the policy window and retires them, booking the leftover balance as breakage.
  • Expiry is itself an atomic conditional write, so a card being spent at the same instant is never wrongly expired.
  • A monthly schedule re-derives every balance from the immutable ledger and checks it against the stored figure.
  • The reconciliation proves one identity: opening liability + issued − redeemed − expired = closing liability, to the cent.
  • Bedrock writes a short plain-English narrative on top of the finished numbers — it never computes or touches a figure.

Retiring a dormant card

Gift cards do not get spent down to zero and closed off neatly. A good fraction are partly used and then forgotten — the $50 card with $31.60 still on it that nobody ever brings back. Left alone, those balances sit on the books as money owed forever, and the outstanding-liability figure slowly loses meaning. Where local law allows it, the policy sets an expiry window — commonly 24 months with no activity — after which a dormant card is retired and its leftover balance is recognised as breakage: value that was sold but will never be redeemed.

A nightly EventBridge schedule runs the expiry sweep at a quiet hour. It queries for cards whose last-activity timestamp is older than the window and whose status is still active, and retires each one. Crucially, retiring a card is not a blind overwrite — it is an atomic conditional write, the same tool as everywhere else. The sweep says “set this card to expired and book the balance as breakage, only if the last-activity timestamp is still the old one I read.” If the customer happened to spend the card a second before the sweep touched it, the timestamp has moved, the condition fails, and the card is left active — their money is safe. No race between a redemption and the sweep can ever expire a card that was just used.

Each expiry appends a row to the ledger — an expired event with the breakage amount — so the history stays complete and the monthly reconciliation can account for every cent that left the live pool.

Proving the books once a month

The balance on a card row is fast to read, but “fast” is not “trusted.” The trusted number is the one you can rebuild from scratch. Once a month a second EventBridge schedule runs the reconciliation, which re-derives every card’s balance from the immutable ledger — opening row plus every redemption minus, every expiry out — and checks the rebuilt figure against the balance stored on the card row. If a single card disagrees, the report names it rather than papering over it. In normal running they always agree, because every write that moved a balance also appended its ledger row in the same step.

How a card expires and reconciles: a nightly sweep and a monthly report that ties the books together Two scheduled flows inside the dotted AWS account container. The upper flow is the nightly expiry sweep: an EventBridge schedule labelled "nightly" triggers the gcr-expiry Lambda, which queries the cards table for cards dormant past the policy window (last activity older than 24 months and status active). For each, it performs an atomic conditional write that sets status to expired and books the leftover balance as breakage, only if the last-activity timestamp is unchanged since it read it — so a card spent at the same instant is left active. Each expiry appends an expired row to the ledger. The lower flow is the monthly reconciliation: an EventBridge schedule labelled "monthly" triggers the gcr-report Lambda, which reads the entire append-only ledger and re-derives every balance, then checks the identity: opening liability plus issued, minus redeemed, minus expired, equals closing liability. The numbers feed into a small box showing an example: opening $8,420.00, issued +$3,000.00, redeemed minus $2,610.40, expired minus $190.00, closing $8,619.60. The finished numbers are passed to a Bedrock Haiku call that writes a short plain-English narrative, and the report plus a CSV export is written to S3 and emailed to the owner via SES. A bottom note reads: Bedrock writes the words on top of the finished numbers; it never computes a figure. AWS account EventBridge nightly gcr-expiry dormant > 24 months Atomic write: expire if last_activity unchanged book leftover as breakage EventBridge monthly gcr-report re-derive from ledger Reconciliation opening $8,420.00 issued +$3,000.00 redeemed −$2,610.40 expired −$190.00 closing $8,619.60 Bedrock narrative words on top of numbers S3 export + SES to owner report & CSV Bedrock writes the words on top of the finished numbers — it never computes or touches a figure.
Fig 5. Two schedules keep the ledger honest: a nightly sweep expires dormant cards with an atomic conditional write, and a monthly report re-derives every balance from the ledger and ties opening, issued, redeemed, expired, and closing liability together.

The one identity that must hold

The whole reconciliation comes down to a single equation that has to balance to the cent:

opening liability + issued − redeemed − expired = closing liability

Each term is summed straight from the ledger over the month. Opening is last month’s closing figure. Issued is every opening row — the new money the business took on. Redeemed is every redemption row — money handed back as goods. Expired is every breakage row — money recognised as never coming back. Closing is what the business still owes in live cards. A worked month: opening $8,420.00, issued $3,000.00, redeemed $2,610.40, expired $190.00, and so closing $8,619.60. If the rebuilt closing figure does not equal the sum of the live card balances, the report flags the gap loudly — that mismatch is the single most important alarm in the system, because it means a balance moved without a matching ledger row, and the books can no longer be trusted until it is explained.

Words on top of numbers

By the time the report is assembled, every figure is final and was computed in plain Python over integer cents. The last step hands those finished numbers to a single Bedrock Haiku call that writes a short, readable narrative for the owner: how many cards were issued and redeemed, how much was recognised as breakage, how the outstanding liability moved, and anything the report flagged. The model only ever phrases numbers it is given; it never adds them up, never reconciles, and never touches a balance. Switch Bedrock off and every figure in the report is identical — you simply lose the paragraph of prose. The report and a CSV export are written to S3 (versioned, so each month’s close is preserved) and emailed to the owner through SES.

Design rules for expiry and reconciliation

  • Expiry is a scheduled job, and each retirement is an atomic conditional write guarded on last-activity — a just-spent card is never wrongly expired.
  • Breakage is recognised explicitly and appended to the ledger, never quietly dropped.
  • The reconciliation re-derives every balance from the immutable ledger, not from the live row it is checking.
  • One identity must hold to the cent: opening + issued − redeemed − expired = closing. A mismatch is the loudest alarm in the system.
  • Bedrock writes the narrative on top of finished numbers — it never computes a figure.
All posts