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

How an invoice gets sent and handed off

A finished invoice still has to become a real PDF in someone’s inbox, and then it has to be tracked. This post is about the last stretch: rendering the document, sending it through SES, recording it in the ledger as sent, and — the part most generators get wrong — doing nothing further when it goes overdue, except hand it cleanly to a separate chaser that owns the follow-up.

Key takeaways

  • The recorded invoice is poured into an HTML template and rendered to a PDF inside Lambda — no external rendering service.
  • Every rendered PDF is written to a versioned S3 bucket, so the exact document sent to a customer is kept forever.
  • SES emails the PDF to the customer with a short covering note and a view link; the ledger is updated to sent only after SES accepts it.
  • A daily sweep marks any invoice past its due date as overdue — it does not nag, email, or escalate.
  • Overdue invoices are handed to a separate chaser through an SQS queue, with the id, customer, amount, and due date attached.

From a record to a PDF

By the time this stage runs, the ledger already holds a complete, numbered invoice with every line and its workings (Parts 3 and 4). What it doesn’t have yet is a document a customer can actually open. Rendering is deliberately the dullest step in the system: the invoice record is poured into a fixed HTML template — your letterhead, the customer’s details, the line table, the net subtotal, the tax line, the total, the payment terms — and that HTML is converted to a PDF inside the Lambda itself, with a small HTML-to-PDF library packaged in a layer. There’s no headless browser farm and no third-party rendering API to depend on or pay for; the whole conversion happens in the same function, in a fraction of a second, and the only input is the ledger record, so the PDF is a faithful printout of figures that were already computed and saved.

The finished PDF is written to a versioned S3 bucket under a key derived from the invoice number. Versioning matters here for a specific reason: an invoice is a legal-ish document, and the copy the customer received should be retrievable exactly as it went out, even if you later reissue or correct it. With versioning on, a reissue writes a new version rather than overwriting the original, so the audit trail — what was sent, when — is intact by default rather than by discipline.

Sending it

SES emails the PDF to the customer from your verified sending domain, with a short, templated covering message — the invoice number, the amount, the due date, and a link to view it online — and the PDF attached. The order of operations is the careful part. The ledger record is only moved to sent, with the timestamp and the SES message id, after SES has accepted the message for delivery. If SES fails, the record stays in its pre-send state and the work goes back onto the queue to retry; after a few attempts it lands in the dead-letter queue for a human to look at, rather than being silently lost. The result is that the ledger’s “sent” flag means what it says — an invoice marked sent really did leave the building — which is precisely the flag the overdue logic will rely on.

Render to PDF, store versioned, send by SES and mark sent, then the daily sweep hands overdue invoices to a separate chaser Top row, left to right: a Ledger record box feeds a Render step (HTML template to PDF inside Lambda), which writes to a versioned S3 PDF bucket and passes the document to a Send step (SES). Send emails the PDF to the Customer box on the right, and only on SES acceptance writes back to the ledger to mark the invoice sent, shown by an arrow returning to the ledger. A failure path from Send goes to an SQS dead-letter queue for a human. Bottom row: a Daily sweep box reads the ledger, finds invoices whose due date has passed while still unpaid, marks them overdue in the ledger, and pushes a small message onto an SQS hand-off queue. An arrow leaves that queue to an external box, Invoice chaser, labelled separate system, carrying the invoice id, customer, amount, and due date. A note reads: the generator sends and records, then stops; chasing belongs to a separate system. Ledger record numbered invoice Render HTML → PDF in Lambda S3 (versioned) every PDF kept Send (SES) PDF + covering note Customer invoice in inbox mark sent only on SES accept DLQ failed sends, for a human Daily sweep finds overdue, marks it reads ledger Hand-off queue SQS Invoice chaser separate system id, amount, due The generator sends and records, then stops — chasing belongs to a separate system.
Fig 5. Render the PDF, store it versioned, send by SES, and mark the invoice sent only once SES accepts it. The daily sweep marks overdue invoices and hands them to a separate chaser through a queue — it never nags from here.

Overdue is a clean hand-off, not a nag

Here is the decision that keeps this system small. When an invoice passes its due date unpaid, the generator does almost nothing. A daily EventBridge sweep reads the ledger, finds every invoice that is marked sent, is past its due date, and isn’t marked paid, and changes its status to overdue. Then — and this is the whole point — it stops. It does not send a reminder, it does not email the customer, it does not start a polite-then-firm sequence. It simply pushes a small message onto an SQS hand-off queue: the invoice id, the customer, the amount, and the due date. A separate invoice chaser, with its own tone, its own escalation ladder, and its own record of who’s been contacted, picks it up from there.

Splitting the two is a deliberate boundary. Generating an invoice and chasing one are different jobs with different rhythms: generation is a clean, once-a-cycle event that must be exactly right; chasing is a judgement-heavy, multi-step conversation that depends on relationships and history. Bolting the chaser onto the generator would make the generator harder to reason about and would couple a system you want to be boringly predictable to one that’s necessarily fiddly. By handing overdue invoices across a queue, the generator stays a clean biller, the chaser owns all the follow-up, and either can be changed without touching the other. (How the chaser actually pursues a payment — the cadence, the wording, the escalation — is its own system, and its own series.)

What “paid” looks like

The generator doesn’t take payment, so it learns an invoice is settled the same way it learns anything else — from the contract source. When a payment is recorded against an invoice (in the accounting system or the sheet that feeds the contract store), the next sync marks that ledger row paid, and the daily sweep simply stops considering it. There’s no race and no special case: an invoice that’s already paid before its due date is never marked overdue and never handed to the chaser, because the sweep only ever acts on what the ledger says right now. The generator’s entire relationship with payment is to read whether it happened — never to chase it, and never to process it.

Why this shape

  • Render in Lambda, store versioned. No external rendering service, and the exact PDF sent is kept forever in S3.
  • Mark sent only after SES accepts. The “sent” flag is trustworthy, because nothing else is allowed to set it.
  • Failed sends retry, then surface. A dead-letter queue means a send can’t silently vanish.
  • Overdue is a status change and a hand-off. The generator marks it and queues it — it never nags.
  • Chasing is a separate system. Generation stays predictable; follow-up logic lives where it belongs.
All posts