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

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) and gc-ledger (append-only history), both with PITR.
  • The atomic redemption is one UpdateItem with a four-clause condition; the issue is a two-write TransactWriteItems.
  • 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.

Full architecture of the gift card ledger: Lambdas, schedules, tables, S3, SES, and the conditional-write design, in one region The whole system inside the dotted AWS account container, region eu-west-2. Across the top, two external sources: points of sale (tills and online checkout) call the issue and redeem functions, and EventBridge Scheduler drives the two scheduled functions. Four Lambda functions sit in a row, all Python 3.14 on arm64: gcr-issue, gcr-redeem, gcr-expiry, and gcr-report. gcr-issue performs a TransactWriteItems with two operations: a Put into gc-cards with condition attribute_not_exists(code), and a Put of the opening row into gc-ledger. gcr-redeem performs a single UpdateItem against gc-cards with a four-clause condition expression: balance >= amount, amount <= ceiling, spent_today + amount <= daily, and recent_count < velocity; on success it subtracts the balance, increments the counters, and the function appends a redemption row to gc-ledger. gcr-expiry, triggered nightly, queries gc-cards for dormant cards and performs conditional UpdateItems guarded on last_activity, appending expired rows to gc-ledger. gcr-report, triggered monthly, scans gc-ledger to re-derive balances, calls Bedrock Haiku for the narrative, and writes a report and CSV to S3 and emails the owner via SES. Below sit the two DynamoDB tables: gc-cards keyed on code, and gc-ledger keyed on code as partition key and a timestamp-sequence sort key. To the side, an S3 bucket (versioned) for reports and exports, SES for receipts and the report, an SQS queue with a dead-letter queue for receipt and alert delivery, Secrets Manager for one secret, CloudWatch Logs at 7-day retention, and AWS Budgets. Arrows connect the functions to the tables, S3, SES, and the queue. A bottom note repeats that no model touches a balance. Points of sale tills & online checkout — issue / redeem EventBridge Scheduler nightly sweep · monthly report AWS account — region eu-west-2 gcr-issue TransactWriteItems Put card (attr_not_exists) + Put opening row gcr-redeem UpdateItem 4-clause condition + append redemption gcr-expiry nightly Query dormant UpdateItem if last_activity unchanged gcr-report monthly scan ledger re-derive + Bedrock narrative gc-cards (on-demand, PITR) PK: code balance, status, expiry, counters gc-ledger (on-demand, PITR) PK: code · SK: ts#seq append-only events S3 (versioned) reports, CSV, snapshots SES receipts & report SQS + DLQ delivery buffer Secrets Manager one secret CloudWatch Logs 7-day AWS Budgets — alert No model touches a balance — Bedrock writes one monthly paragraph and nothing else.
Fig 7. The full ledger in one region: four Lambdas over two on-demand DynamoDB tables, with the atomic redemption as a single conditional 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 a TransactWriteItems of two operations: a Put into gc-cards with ConditionExpression: attribute_not_exists(code), and a Put of the opening row into gc-ledger. Idempotent on a client request id.
  • gcr-redeem — Python 3.14, arm64. The hot path. One UpdateItem on gc-cards carrying the four-clause condition and the counter updates; on success it appends a redemption row to gc-ledger. Catches ConditionalCheckFailedException and 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. Queries gc-cards for cards past the dormancy window, then issues conditional UpdateItem calls guarded on last_activity to set status = expired and book breakage, appending an expired row to gc-ledger for each.
  • gcr-report — Python 3.14, arm64. Triggered monthly. Scans gc-ledger for 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, invoking gcr-expiry once a day.
  • Monthly reconciliation — EventBridge Scheduler, first of the month, invoking gcr-report once.

DynamoDB tables and key schema

  • gc-cards — on-demand, PITR on. Partition key code (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 issue request_id. The redemption is a single UpdateItem with UpdateExpression: SET balance = balance - :amt, spent_today = ..., recent_count = ... and ConditionExpression: 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 key code (string), sort key ts#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 denies DeleteItem on 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-issue gets PutItem/TransactWriteItems on the two tables; gcr-redeem gets UpdateItem on gc-cards and PutItem on gc-ledger; gcr-expiry gets Query/UpdateItem on gc-cards and PutItem on gc-ledger; gcr-report gets read-only Query/Scan plus S3 PutObject, SES SendEmail, and Bedrock InvokeModel. DeleteItem on gc-ledger is 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 UpdateItem doing the balance and all three caps at once.
  • The ledger is append-only and DeleteItem is 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.
All posts