Part 7 of 7 · Recurring invoice generator series ~8 min read

Engineering reference: the recurring invoice generator architecture

This is the recurring invoice generator with the friendly labels removed: the real resource names, the runtime, the scheduler design that gives each contract its own cycle, the table key schemas, the PDF bucket, and the IAM scope. If you want to build it rather than understand it, start here.

Key takeaways

  • Six Lambda functions, all Python 3.14 on arm64, wired through one SQS work queue with a dead-letter queue.
  • Three DynamoDB tables, all on-demand: a contracts mirror, an append-only invoice ledger, and a single atomic number counter.
  • EventBridge Scheduler holds one entry per contract for its billing cycle, plus one daily catch-up-and-overdue sweep.
  • Rendered invoice PDFs live in a versioned S3 bucket; SES sends them; an SQS hand-off queue passes overdue invoices to a separate chaser.
  • Bedrock (Claude Haiku 4.5, Global inference) is optional and called only by the builder, for description text. Single region, eu-west-2.

The architecture, for engineers

This is the same system as Part 1 with the friendly labels removed and the real resources named. Everything is in one region, eu-west-2 (London), in one account. There is no API Gateway, no NAT Gateway, and nothing always-on; billing is driven entirely by EventBridge Scheduler, work is buffered on a single SQS queue, and the only outbound surface is SES.

Engineering architecture: EventBridge Scheduler, six Lambdas, an SQS work queue, three DynamoDB tables, a versioned S3 bucket, SES, and the chaser hand-off Left column: EventBridge Scheduler with per-contract cycle entries and one daily sweep entry. The per-contract entries target rigr-builder; the daily entry targets rigr-sweep; a separate rate schedule targets rigr-drive-sync. rigr-scheduler is a small function that maintains the per-contract Scheduler entries, creating them when rigr-drive-sync mirrors a new contract and rescheduling them after each build. Centre column, the processing Lambdas, all Python 3.14 arm64: rigr-builder loads a contract, gathers lines, draws a number from rigr-counter, computes proration, tax and totals, optionally calls Bedrock to polish descriptions, and writes the invoice to rigr-ledger; it enqueues a render job on rigr-jobs (with rigr-jobs-dlq). rigr-render reads the ledger record, renders HTML to PDF in-process, and writes the versioned object to the rigr-pdfs S3 bucket. rigr-send emails the PDF via SES and marks the ledger record sent only on SES acceptance. Right column, three DynamoDB tables on-demand: rigr-contracts keyed by contract_id; rigr-ledger keyed by contract_id with sort key cycle, plus a GSI on status and due_date for the sweep and a GSI on invoice_no; rigr-counter, a single item incremented atomically for invoice numbers. Bottom: rigr-sweep scans rigr-ledger via the status/due_date GSI, marks overdue invoices, and pushes them onto the rigr-handoff SQS queue. External boxes outside a dotted AWS account container: Customers receive SES mail, and a separate Invoice chaser consumes rigr-handoff. Bedrock is called only by rigr-builder. AWS account · eu-west-2 EventBridge Scheduler per-contract + daily rigr-scheduler maintains cycle entries rigr-drive-sync mirror contracts, rate(15m) rigr-sweep daily catch-up + overdue rigr-builder lines, proration, tax, totals fires builder rigr-jobs SQS + rigr-jobs-dlq rigr-render HTML → PDF in-process rigr-send SES, mark sent on accept Bedrock Haiku 4.5, Global — optional rigr-contracts PK contract_id rigr-ledger PK contract_id, SK cycle GSI status+due_date, GSI invoice_no rigr-counter atomic invoice number rigr-pdfs (S3) versioned, every PDF rigr-handoff SQS overdue queue sweep reads GSI Customers SES out Invoice chaser separate system All Lambdas: Python 3.14, arm64 · Secrets Manager (Drive, signing) · CloudWatch Logs 7-day retention · AWS Budgets cost alarm
Fig 7. The recurring invoice generator drawn for engineers: EventBridge Scheduler driving six Lambdas, an SQS-buffered render-and-send chain, three DynamoDB tables, a versioned PDF bucket, SES out, and a hand-off queue to a separate chaser. One region, one account, no API Gateway.

Lambda functions

Six functions, all Python 3.14 on arm64, all with CloudWatch Logs at 7-day retention. Each does one job; the SQS queue (rigr-jobs, with rigr-jobs-dlq as its dead-letter queue after five attempts) decouples the build from the slower render-and-send.

  • rigr-drive-sync — scheduled at rate(15 minutes). Pulls the contracts sheet and rules doc from Drive, writes a snapshot, and upserts rows into rigr-contracts. On a new or changed contract it calls rigr-scheduler to create or adjust the cycle entry.
  • rigr-scheduler — maintains the per-contract EventBridge Scheduler entries: creates one when a contract appears, updates it when the cycle or anchor changes, and reschedules it for the next due date after each build. It writes no invoice data.
  • rigr-builder — the cycle target. Loads the contract, gathers lines (including prorated catch-ups), draws the next number from rigr-counter, computes proration, tax and totals in plain Python, optionally calls Bedrock for description text, writes the invoice to rigr-ledger with a conditional put on contract_id+cycle, and enqueues a render job.
  • rigr-render — SQS-triggered. Reads the ledger record, renders the HTML invoice template to a PDF in-process, and writes the object to rigr-pdfs.
  • rigr-send — emails the PDF via SES with the covering note and view link, then updates the ledger record to sent with the SES message id — only on acceptance.
  • rigr-sweep — scheduled daily. Two jobs: build any invoice whose due date passed without a ledger record (the catch-up), and mark any sent-but-unpaid invoice past due as overdue, pushing it onto rigr-handoff for the chaser.

Data stores, schedules, and mail

  • DynamoDB (all on-demand). rigr-contracts — PK contract_id, the mirrored contract with line items, cycle, anchor, term, and jurisdiction. rigr-ledger — PK contract_id, SK cycle (the billing period), holding the invoice, its status, its due date, and the full workings; a GSI on status+due_date drives the sweep, and a GSI on invoice_no gives direct lookup by number. The contract_id+cycle key is what makes the build idempotent. rigr-counter — a single item incremented with an atomic UpdateItem that returns the new value, the source of gap-free invoice numbers.
  • S3. rigr-pdfs — versioning enabled, one object per invoice keyed by number; a reissue writes a new version rather than overwriting, preserving exactly what each customer received.
  • SES. Verified sending domain with DKIM, used outbound only — there is no inbound mail in this system. Each send carries the rendered PDF and a templated covering message.
  • EventBridge Scheduler. One entry per contract (grouped) targeting rigr-builder on the contract’s due date and self-rescheduled after each build; one rate(15 minutes) entry for rigr-drive-sync; one daily cron entry (early morning) for rigr-sweep.
  • SQS. rigr-jobs (+ rigr-jobs-dlq) buffers render-and-send; rigr-handoff is the one-way queue to the separate invoice chaser.
  • Secrets Manager. Two secrets — the Drive service-account key and the view-link signing key; fetched at call time, never in env vars or the sheet.
  • Bedrock. Model id anthropic.claude-haiku-4-5 via the Global cross-Region inference profile, invoked only by rigr-builder, and only for line descriptions — never a figure.

IAM scope and region

Each function gets its own execution role scoped to exactly what it touches, no wildcards. rigr-drive-sync can read the Drive secret, write rigr-contracts, and invoke rigr-scheduler; it cannot touch the ledger. rigr-scheduler can manage Scheduler entries in its group and nothing else — no table access at all. rigr-builder can read rigr-contracts, increment rigr-counter, write rigr-ledger, send to rigr-jobs, and is the only role with bedrock:InvokeModel, scoped to the one Haiku profile; it cannot send mail. rigr-render can read the ledger and write rigr-pdfs — no SES, no Bedrock. rigr-send can read a PDF, send via SES, and update a ledger record’s status, but cannot delete from any table. rigr-sweep can read the ledger GSI, update status, and send to rigr-handoff — nothing more. Everything runs in eu-west-2; the only cross-Region path is Bedrock’s Global inference profile, which routes the optional description call for capacity and is not a data store. An AWS Budgets alarm watches the monthly spend and notifies if it drifts above a few dollars — the early signal that a schedule or a render loop is misbehaving.

Design rules that shaped the build

  • One job per function. Six small Lambdas beat one that does everything; the queue decouples render and send from the build.
  • No public surface. There is no API Gateway and no inbound mail — everything is driven by Scheduler and SQS.
  • Least privilege, per role. Only the builder can call Bedrock and increment the counter; only send can use SES.
  • Idempotency at the database. The contract_id+cycle key and a conditional put make double-billing impossible.
  • One region, one optional model. eu-west-2 throughout; Bedrock Haiku 4.5 via Global inference, called only to polish words.
  • A budget alarm is a smoke detector. The cheapest way to learn a schedule looped is a Budgets alert.
All posts