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.
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 atrate(15 minutes). Pulls the contracts sheet and rules doc from Drive, writes a snapshot, and upserts rows intorigr-contracts. On a new or changed contract it callsrigr-schedulerto 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 fromrigr-counter, computes proration, tax and totals in plain Python, optionally calls Bedrock for description text, writes the invoice torigr-ledgerwith a conditional put oncontract_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 torigr-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 ontorigr-handofffor the chaser.
Data stores, schedules, and mail
- DynamoDB (all on-demand).
rigr-contracts— PKcontract_id, the mirrored contract with line items, cycle, anchor, term, and jurisdiction.rigr-ledger— PKcontract_id, SKcycle(the billing period), holding the invoice, its status, its due date, and the full workings; a GSI onstatus+due_datedrives the sweep, and a GSI oninvoice_nogives direct lookup by number. Thecontract_id+cyclekey is what makes the build idempotent.rigr-counter— a single item incremented with an atomicUpdateItemthat 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-builderon the contract’s due date and self-rescheduled after each build; onerate(15 minutes)entry forrigr-drive-sync; one dailycronentry (early morning) forrigr-sweep. - SQS.
rigr-jobs(+rigr-jobs-dlq) buffers render-and-send;rigr-handoffis 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-5via the Global cross-Region inference profile, invoked only byrigr-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+cyclekey and a conditional put make double-billing impossible. - One region, one optional model.
eu-west-2throughout; 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.