Engineering reference: the gift card ledger architecture
This is the reference post: the gift card ledger drawn for someone who is going to build it. No shop-floor framing — just the resources and how they fit. The Lambda inventory with the gcr- prefix, the two EventBridge schedules, the DynamoDB tables with their partition and sort keys, the precise conditional expression behind an atomic redemption and the transaction behind an issue, the S3 layout, the SES senders, the IAM scopes, and the one region everything lives in. Read part 3 first if you want the why; this is the what.
Key takeaways
- Four Lambdas (
gcr-issue,gcr-redeem,gcr-expiry,gcr-report), all Python 3.14 on arm64, in one region. - Two DynamoDB tables on-demand:
gc-cards(live state) andgc-ledger(append-only history), both with PITR. - The atomic redemption is one
UpdateItemwith a four-clause condition; the issue is a two-writeTransactWriteItems. - Two EventBridge schedules drive the nightly expiry sweep and the monthly reconciliation report.
- IAM is scoped per function to the exact tables and actions it needs; everything lives in
eu-west-2.
The architecture, for engineers
Same system as the rest of the series, drawn without the shop-floor framing. Every resource carries the gcr- prefix; everything sits in one region.
UpdateItem and the issue as a two-write transaction. S3, SES, SQS, Secrets Manager, CloudWatch, and Budgets support the edges.Lambda inventory
gcr-issue— Python 3.14, arm64. Mints a code from a CSPRNG, sets the opening balance in cents and the expiry date, and commits aTransactWriteItemsof two operations: aPutintogc-cardswithConditionExpression: attribute_not_exists(code), and aPutof the opening row intogc-ledger. Idempotent on a client request id.gcr-redeem— Python 3.14, arm64. The hot path. OneUpdateItemongc-cardscarrying the four-clause condition and the counter updates; on success it appends a redemption row togc-ledger. CatchesConditionalCheckFailedExceptionand either retries (race) or returns the real balance (insufficient), and freezes the card on a cap trip.gcr-expiry— Python 3.14, arm64. Triggered nightly. Queriesgc-cardsfor cards past the dormancy window, then issues conditionalUpdateItemcalls guarded onlast_activityto setstatus = expiredand book breakage, appending anexpiredrow togc-ledgerfor each.gcr-report— Python 3.14, arm64. Triggered monthly. Scansgc-ledgerfor the period, re-derives every balance, checks the reconciliation identity, calls Bedrock Haiku 4.5 for the narrative, and writes the report and CSV to S3 and emails the owner via SES.
Schedules
- Nightly expiry sweep — EventBridge Scheduler, a fixed off-peak time in
eu-west-2, invokinggcr-expiryonce a day. - Monthly reconciliation — EventBridge Scheduler, first of the month, invoking
gcr-reportonce.
DynamoDB tables and key schema
gc-cards— on-demand, PITR on. Partition keycode(string), no sort key — one item per card. Attributes:balance(number, integer cents),status(active/expired/void/frozen),issued_at,expires_at,last_activity,spent_today,today(date stamp),recent_count,window_start, and the issuerequest_id. The redemption is a singleUpdateItemwithUpdateExpression: SET balance = balance - :amt, spent_today = ..., recent_count = ...andConditionExpression: balance >= :amt AND :amt <= :ceiling AND spent_today + :amt <= :daily AND recent_count < :velocity. The whole hot path is one network round trip.gc-ledger— on-demand, PITR on. Partition keycode(string), sort keyts#seq(ISO timestamp plus a sequence suffix) so a card’s events sort in order and are unique. Attributes per row:event(issue/redeem/expire/freeze),amount(cents),balance_after, and source metadata. Append-only by convention — no function ever updates or deletes a ledger row, and IAM deniesDeleteItemon this table.
S3, SES, SQS, and Secrets Manager
- S3 — one versioned bucket holding monthly reports, CSV exports, and periodic ledger snapshots. Versioning means each month’s close is preserved even if a later write lands on the same key.
- SES — outbound only: issue receipts, redemption receipts (with remaining balance), freeze alerts to the owner, and the monthly report.
- SQS + DLQ — buffers receipt and alert sends so a transient SES hiccup never blocks a redemption; anything that fails repeatedly lands in the dead-letter queue for inspection.
- Secrets Manager — one secret holding SES and configuration credentials, read at cold start.
IAM, observability, and region
- IAM — one execution role per function, scoped to exactly what it touches.
gcr-issuegetsPutItem/TransactWriteItemson the two tables;gcr-redeemgetsUpdateItemongc-cardsandPutItemongc-ledger;gcr-expirygetsQuery/UpdateItemongc-cardsandPutItemongc-ledger;gcr-reportgets read-onlyQuery/Scanplus S3PutObject, SESSendEmail, and BedrockInvokeModel.DeleteItemongc-ledgeris granted to nobody. - CloudWatch Logs — 7-day retention on every function. AWS Budgets — two budgets with an email alert, so a runaway cost is caught early.
- Region — everything in
eu-west-2. No API Gateway, no NAT Gateway, no VPC-bound compute, no always-on anything.
The whole system in one breath
- Four Lambdas, two tables, two schedules, one region, one secret.
- Issue is a two-write transaction; redeem is one conditional
UpdateItemdoing the balance and all three caps at once. - The ledger is append-only and
DeleteItemis granted to no role — history cannot be rewritten. - Bedrock appears once a month for prose; every cent is computed in plain Python over integers.
- About $2.10/month at SMB volume, and nothing in the design grows into a server.