Design an Invoice Generation System
Design an invoice generator step by step: financial snapshots, immutable documents, async PDFs, object storage, transactional outbox, idempotency, and the reasoning behind every major decision.
Page content
Alice pays for order ORD-123. Checkout calls POST /v1/invoices with Idempotency-Key: abc123. The network times out. Checkout retries the same request. A second later two PDF workers both see InvoiceCreated.
That race is the product. Alice must receive exactly one invoice, with the prices she was charged, not the prices in today’s catalog. The PDF can arrive a few seconds later. An email can arrive after the PDF. Neither the PDF renderer nor the mail provider may decide whether the invoice exists.
The main design question is:
How do we issue an invoice exactly once, freeze the money that was charged, and generate PDFs and notifications asynchronously without losing events or sending them twice?
We will start with one PostgreSQL database and a small API. Object storage, a broker, and workers appear only after a concrete limitation appears.
1. Clarify the problem
“Design invoicing” can mean tax engines, multi-currency ledgers, credit notes, recurring billing, and a full ERP. That is too broad for one interview.
I would ask:
- Does an order have at most one invoice?
- Is the invoice a legal snapshot of the paid order, or a live view of current prices?
- Must the create API wait for a PDF?
- Who downloads invoices: the customer, finance, or both?
- Do we send email, or is in-app download enough?
- What volume of invoices and PDFs should we support?
- Are we on AWS, or should storage and the broker be replaceable?
If the interviewer gives no extra constraints, I would state:
Product Issue one invoice per paid order
Not in scope Tax rules engine, credit notes, dunning, ERP
Identity UUID invoice id; human number INV-2026-00000123
Money Immutable snapshot at create time
PDF Asynchronous; stored in object storage
Download Short-lived URL after authorization
Notify After the PDF exists; mocked email
Auth Customer sees own invoices; admin sees all
Deployment One region first
Stack Go, PostgreSQL, Redis optional, broker + storage abstractions
Local filesystem, a fake mailer, and an in-process or Docker broker are enough to explain the architecture. S3, Kafka, and SES plug in behind interfaces later. That is an interview strength, not a shortcut.
2. Functional requirements
The system must:
- Create an invoice from an order.
- Store invoice metadata and immutable line items.
- Generate a PDF asynchronously.
- Store the PDF in object storage.
- Let an authorized caller download the invoice.
- Notify the customer asynchronously after the PDF exists.
- Tolerate duplicate create requests and duplicate events.
- Retry failed PDF and notification work, then dead-letter.
- Regenerate a PDF without changing financial fields.
The first version does not include:
- tax jurisdiction logic beyond storing the amounts given by Order Service;
- credit notes, partial refunds, or invoice revisions of money;
- recurring subscriptions;
- a real email provider or a real S3 account; or
- a public, unauthenticated bucket of PDFs.
3. Non-functional requirements
The important quality targets are:
| Requirement | Target |
|---|---|
| Create latency | p99 below 300 ms excluding PDF and email |
| PDF freshness | Seconds to a minute is acceptable |
| Durability | Never lose an acknowledged invoice |
| Exactly one invoice per order | Enforced in the database |
| Idempotent HTTP | Same Idempotency-Key returns the original invoice |
| Immutability | Finalized money fields never change |
| Download | Authorize in the API; do not expose the bucket |
| Degraded PDF | Create still succeeds if the renderer or broker is down |
Consistency is not one global setting.
Invoice row and line items source of truth; read-your-write after POST
PDF bytes derived rendering in object storage
PDF status derived lifecycle on the invoice row
Email derived delivery; at-least-once, idempotent
Catalog prices irrelevant after the snapshot is stored
Edge cases to keep in mind
- Checkout retries create with the same idempotency key.
- Two keys try to invoice the same order.
- Two PDF workers claim
ORD-123. - PDF generation fails after the invoice is finalized.
InvoiceCreatedis delivered twice.InvoicePDFGeneratedis delivered twice and would email Alice twice.- Download is requested while
pdf_statusis stillPENDING. - A customer requests another customer’s invoice.
- Object storage is down during upload.
- Product prices change five minutes after payment.
4. Estimate the scale
Use round numbers. The goal is to find what is not the bottleneck.
Assume a sizable commerce site:
Invoices created 500,000/day
Average create QPS 500,000 / 86,400 ≈ 6
Peak create ~100–500/s
PDF size ~200 KB
Downloads 2 million/day
Metadata is small. PDFs are not:
500,000 × 200 KB × 365 ≈ 36 TB/year of PDF bytes
That number is why PDFs do not live in PostgreSQL. Invoice rows and line items are a few kilobytes. PostgreSQL can hold years of them. Object storage holds the files. The create path is a modest transactional write. The PDF path is a throughput and retry problem.
Where scale still matters:
- Invoice-number allocation contends on a year (or company) counter.
- A payment retry storm hits the same
order_id. - Month-end accountants download last month’s PDFs.
- A poisoned PDF template must not block the outbox forever.
5. What are we storing?
We are storing a legal document and two derived jobs.
Invoice the issued document: who, what, how much
Invoice item a frozen line: quantity, unit price, tax, line total
Invoice number human id INV-2026-00000123
PDF a rendering of that document
Notification a delivery attempt of that rendering
Outbox a durable “this invoice exists” event
The catalog is not part of the invoice. If SKU-44’s price changes tomorrow, INV-2026-00000123 does not.
Keep three lifecycles independent:
Invoice PENDING | FINALIZED | CANCELLED
PDF PENDING | PROCESSING | GENERATED | FAILED
Notification PENDING | SENT | FAILED
A failed PDF does not un-finalize the invoice. Alice was charged. Finance still has a document. We retry the rendering.
For the first version, POST /v1/invoices writes a FINALIZED snapshot in one transaction. PENDING on the invoice itself is useful if the product later needs drafts. We do not need a draft to explain the architecture. CANCELLED is an explicit legal cancel, not a renderer crash.
Source of truth invoices, invoice_items, invoice numbers
Derived data PDF object, notification rows
Cache optional hot GET invoice:{id}
Event system outbox → broker
Ephemeral state pre-signed URLs, worker leases
6. APIs
Keep the API independent of Kafka, S3, and the PDF library.
Create invoice
POST /v1/invoices
Authorization: Bearer <token>
Idempotency-Key: abc123
{
"order_id": "ORD-123"
}
HTTP 201 Created
{
"id": "inv_8f3a",
"invoice_number": "INV-2026-00000123",
"order_id": "ORD-123",
"customer_id": "u_alice",
"status": "FINALIZED",
"currency": "USD",
"subtotal": "80.00",
"discount": "5.00",
"tax": "6.00",
"total": "81.00",
"pdf_status": "PENDING",
"version": 1,
"created_at": "2026-08-17T10:01:00Z",
"finalized_at": "2026-08-17T10:01:00Z"
}
The HTTP response does not include PDF bytes. Create is the financial commit. Rendering is a follow-up job.
A replay of the same key returns the original invoice with 200 (or 201 if you stored that status on the idempotency row). A second invoice for ORD-123 returns 409 INVOICE_ALREADY_EXISTS.
Get invoice
GET /v1/invoices/inv_8f3a
Line items are part of the snapshot. They are not loaded from the product catalog.
List by customer
GET /v1/customers/u_alice/invoices?limit=50&cursor=...
Cursor pagination, not OFFSET. Alice may only list Alice.
Download
GET /v1/invoices/inv_8f3a/download
HTTP 200 OK
{
"download_url": "https://files.example.com/invoices/2026/08/inv_8f3a.pdf?sig=...",
"expires_at": "2026-08-17T10:16:00Z"
}
If pdf_status != GENERATED:
HTTP 409 Conflict
{
"error": {
"code": "PDF_NOT_READY",
"message": "Invoice PDF is not ready"
}
}
Regenerate PDF
POST /v1/invoices/inv_8f3a/regenerate
Authorization: Bearer <admin>
This does not touch subtotal, tax, or line items. It asks the PDF worker to render the same snapshot again.
Errors
400 VALIDATION_ERROR, TOTALS_MISMATCH
401 UNAUTHENTICATED
403 UNAUTHORIZED_INVOICE_ACCESS
404 INVOICE_NOT_FOUND, ORDER_NOT_FOUND
409 INVOICE_ALREADY_EXISTS, IDEMPOTENCY_CONFLICT,
PDF_NOT_READY, INVALID_INVOICE_STATE
503 STORAGE_ERROR, DEPENDENCY_UNAVAILABLE
Do not return SQLSTATE or S3 XML to clients.
7. Basic data model
Invoice and items
invoices
--------
id UUID PK
order_id unique
customer_id
invoice_number unique
status FINALIZED | CANCELLED | PENDING
currency
subtotal, discount, tax, total numeric(19,4)
pdf_status PENDING | PROCESSING | GENERATED | FAILED
pdf_object_key nullable
created_at
finalized_at
version
invoice_items
-------------
id
invoice_id FK → invoices.id
product_id snapshot id, not a live join
description snapshot text
quantity
unit_price
discount
tax
total
Store money as decimals, not floats. Persist the description and unit price that were true at issue time. product_id is an audit trail back to the catalog, not a price lookup.
Indexes:
invoices(order_id) uniqueness and payment retries
invoices(customer_id, id) customer list + cursor
invoices(invoice_number)
invoices(status)
invoices(pdf_status) operator dashboards, not the worker claim
Numbers, idempotency, outbox, notifications
invoice_number_counters
-----------------------
year PK
next_number
idempotency_records
-------------------
key PK
request_hash
invoice_id
response_code
created_at
outbox_events
-------------
id
event_type
aggregate_type
aggregate_id
payload
status PENDING | PUBLISHED | FAILED
retry_count
created_at
published_at
notifications
-------------
id
invoice_id
event_id unique with consumer, or unique event_id
status PENDING | SENT | FAILED
created_at
UNIQUE(order_id) is the business invariant. idempotency_records is the HTTP retry invariant. They answer different races.
8. Start with one service and PostgreSQL
Do not begin with Invoice Service, PDF Service, and Notification Service as three deployables. One Go process can host the HTTP API, the outbox publisher, and the workers. Packages keep the boundaries obvious so they can split later.
Client
│
▼
HTTP Handler
│ authn, idempotency key, JSON
▼
Invoice Service
│ snapshot, totals, transactions
▼
Repository
│
▼
PostgreSQL
An OrderService interface loads the paid order. In production it is another team’s API. In the interview it is a stub that returns line items. The invoice must not call it again on GET.
Create, in one transaction:
BEGIN
claim idempotency key
reject if order already invoiced
lock year counter, allocate INV-2026-00000123
insert invoice FINALIZED, pdf_status PENDING
insert invoice_items
insert outbox InvoiceCreated
COMMIT
return invoice
No Kafka call, no PDF render, no SMTP inside that transaction.
9. Snapshot the money, then freeze it
If we stored only order_id and computed totals on read:
Day 1 Alice pays $81, SKU-44 is $40
Day 2 catalog price becomes $50
Day 3 GET invoice shows $91
That is a finance bug, not a cache bug. The invoice is evidence of what was charged. Copy every amount onto invoice_items at create time. Validate:
item.total = quantity * unit_price - discount + tax
invoice.subtotal + invoice.tax - invoice.discount = invoice.total
If Order Service’s totals disagree, fail the create. Do not “fix” money in the invoice service.
After FINALIZED, there is no PATCH for amounts. A credit note, if you add one later, is a new document that references this one.
This is why PDF regeneration is allowed and amount edits are not. The file is a view. The rows are the books.
10. Three state machines, not one status column
A single status = PDF_FAILED on the invoice would imply the document is invalid. It is not.
Invoice FINALIZED
pdf_status FAILED
notification FAILED
is a coherent state: the legal invoice exists, rendering needs an operator, mail has not gone out.
Transitions:
Invoice
(create) → FINALIZED
FINALIZED → CANCELLED explicit finance operation
PDF
PENDING → PROCESSING worker claim
PROCESSING → GENERATED upload ok
PROCESSING → FAILED render or upload failed
FAILED → PROCESSING retry or regenerate
GENERATED → PROCESSING regenerate
Notification
PENDING → SENT
PENDING → FAILED
FAILED → PENDING retry
Conditional SQL, not “read, decide, write” in two statements:
UPDATE invoices
SET pdf_status = 'PROCESSING', version = version + 1
WHERE id = $id
AND pdf_status IN ('PENDING', 'FAILED')
Zero rows means another worker already claimed it, or it is already GENERATED. That is how we survive two consumers and at-least-once delivery.
11. Why create is synchronous and PDF is not
Alice’s checkout needs to know the invoice id and number before the page closes. That write is a small transaction: a few rows, a counter, an outbox event.
PDF generation is different:
load snapshot
render template
produce 200 KB
PUT to object storage
That can take hundreds of milliseconds to several seconds, depends on a template engine, and fails independently of PostgreSQL. Putting it in POST /v1/invoices would:
- hold a database connection while rendering;
- fail the financial commit because wkhtmltopdf crashed;
- make every payment retry wait on storage.
So the API finalizes money and returns. The worker renders when it can. The download API tells the truth if the file is not ready: PDF_NOT_READY.
12. Invoice numbers without an in-memory counter
INV-2026-00000123 is not the primary key. The UUID is. The pretty number is allocated like a ticket number.
Two API instances must not both issue ...0123.
BEGIN
SELECT next_number
FROM invoice_number_counters
WHERE year = 2026
FOR UPDATE
-- allocate, increment, insert invoice
COMMIT
A unique constraint on invoice_number is the backstop. Gaps are allowed if a transaction rolls back. Duplicates are not.
An in-memory ++counter is wrong the moment you run two pods. A Redis INCR is a possible allocator, but then Redis is on the create path and can disagree with PostgreSQL. Keep allocation in the same database as the invoice row.
13. Two kinds of “do not create twice”
Checkout retries are not the same as two orders.
Idempotency key (HTTP):
INSERT idempotency_records (key, request_hash, ...)
ON CONFLICT (key):
if hash matches: return stored invoice
else: 409 IDEMPOTENCY_CONFLICT
Store the hash of the body. The same key must not mean two different order_ids.
One invoice per order (business):
UNIQUE (order_id)
If two different keys arrive for ORD-123, the second insert fails and the API returns 409 INVOICE_ALREADY_EXISTS (or the existing invoice, if that is the product rule). The unique constraint, not a SELECT then INSERT, is what two pods cannot evade.
Create therefore does both checks inside one transaction. Application memory is not involved.
14. Transactional outbox
Do not kafka.Publish inside the PostgreSQL transaction, and do not publish after commit with no durable record.
Postgres commit, publish fails invoice exists, no PDF job
Publish succeeds, Postgres rolls back worker invoices a ghost
The fix is the same pattern as the ticket and employee designs:
BEGIN
INSERT invoice, items
INSERT outbox_events InvoiceCreated
COMMIT
Outbox publisher (later):
SELECT PENDING events
publish to broker
mark PUBLISHED
If the broker is down, rows wait. The invoice is still FINALIZED. PDF lag is visible in pdf_status and in unpublished-outbox age.
Events need enough data for a worker that cannot see a half-written row:
InvoiceCreated
event_id, invoice_id, order_id, customer_id, created_at
InvoicePDFGenerated
event_id, invoice_id, object_key
InvoicePDFGenerationFailed
event_id, invoice_id, error_code, retry_count
InvoiceNotificationRequested
InvoiceNotificationSent
Partition the broker by invoice_id so one invoice’s events stay ordered. Global order across invoices is unnecessary.
15. PDF worker
InvoiceCreated
│
▼
Broker
│
▼
PDF Worker
│
├─ claim PROCESSING
├─ load invoice + items from PostgreSQL
├─ if already GENERATED: ack and stop
├─ render bytes through PDFGenerator
├─ Put object key invoices/2026/08/inv_8f3a.pdf
├─ set pdf_status GENERATED, pdf_object_key
└─ insert outbox InvoicePDFGenerated
The generator is an interface:
PDFGenerator.Generate(invoice) -> []byte
ObjectStorage.Put / Get / GenerateDownloadURL
A deterministic fake PDF is enough in development. A real library plugs in later. The worker must not import S3 types.
Claim before render so two workers do not both PUT and both email. If render fails after claim, set FAILED and let retry move it back through PROCESSING.
Do not store PDF bytes in PostgreSQL. Backups, page size, and SELECT * get worse, and you still need a download path. Object storage is the blob store; the database stores the key.
16. Download with a short-lived URL
The API is the authorization point. The bucket is not public.
GET /invoices/{id}/download
authenticate
authorize customer_id == actor, or admin
if pdf_status != GENERATED: PDF_NOT_READY
url = ObjectStorage.GenerateDownloadURL(key, 15 minutes)
return url, expires_at
The application does not stream 200 KB through the API pod on every accountant download. It mints a capability that expires. Locally, LocalObjectStorage can return a path your dev server already serves. In production, that method becomes an S3 pre-signed GET.
Never put a long-lived public URL in the invoice JSON. Email can include a link to your download API, which re-checks auth, or a short-lived URL generated at send time.
17. Notify after the file exists
Emailing on InvoiceCreated sends Alice a letter with no attachment, or a link that 409s. Wait for InvoicePDFGenerated.
InvoicePDFGenerated
│
▼
Notification Worker
│
├─ insert notifications row keyed by event_id
├─ mint download URL or app link
└─ NotificationService.SendInvoice(...)
A fake implementation logs the message. The create API never calls it.
If SendInvoice fails, mark FAILED and retry. Do not roll back the invoice. Do not roll back the PDF.
Duplicate InvoicePDFGenerated must not send two emails. Unique event_id (or (consumer, event_id) in processed_events) makes the second insert a no-op.
18. At-least-once, retries, and a DLQ
The broker delivers at least once. Exactly-once across Postgres, Kafka, S3, and SES is not the interview answer.
Duplicate InvoiceCreated
claim PROCESSING fails or sees GENERATED → ack
Duplicate InvoicePDFGenerated
unique event_id on notifications → skip send
Retries with backoff:
1s, 5s, 30s, 5m
After the limit, send the payload to a dead-letter abstraction and set pdf_status or notification status to FAILED. Operators replay from the DLQ. A bad template must not block InvoiceCreated for everyone else: process invoices independently, partitioned by id.
The outbox publisher has its own retry_count. It must not call the PDF renderer. It only publishes.
Never hold the create transaction open during backoff.
19. Regeneration
Templates change. A generated file can be corrupt. Finance still must not rewrite totals.
POST /invoices/{id}/regenerate
authorize admin
require status FINALIZED
set pdf_status PENDING (from GENERATED or FAILED)
insert outbox InvoiceCreated or InvoicePDFRegenerateRequested
Use a conditional update so two regenerate clicks do not start two overlapping renders without a claim. The worker is the same path as create. Line items are untouched.
20. Authorization
A customer token may read and download only customer_id = actor. An internal admin role may read all and regenerate. Authorization is a check in the handler/service after load, not a WHERE clause the client supplies.
Do not implement OAuth in the interview. An Actor{ID, Role} from a header or stub middleware is enough if you always use it.
21. Consistency model
Strong, same transaction
invoice, items, invoice number, idempotency row, outbox
Read-your-write for the actor
GET invoice after POST sees FINALIZED and pdf_status PENDING
Eventual
PDF object, pdf_status GENERATED, email
Unacceptable
two invoices for one order, mutated totals after finalize,
email that proves an invoice the database rolled back
Redis, if you add it, may cache GET /invoices/{id}. Invalidate after regenerate and cancel. Uniqueness still lives in PostgreSQL.
22. Failure scenarios
| Failure | Behavior |
|---|---|
| PostgreSQL down | Create and authoritative GET fail |
| Order Service down | Create fails; no half invoice |
| Broker down | Outbox waits; invoice is FINALIZED |
| PDF renderer down | pdf_status FAILED after retries; invoice remains FINALIZED |
| Object storage down | Same as renderer; no GENERATED key |
| Email provider down | PDF remains; notification retries / DLQ |
| Duplicate events | Conditional PDF claim; unique notification event_id |
| Download before PDF | 409 PDF_NOT_READY |
Degraded behavior is part of the product: the payment is done, the invoice number is issued, the pretty file may lag.
23. Observability and scale
Log request_id, invoice_id, order_id, customer_id, event_id, idempotency_key, retry_count.
Useful counters:
invoice_creation_success_total
invoice_creation_failure_total
pdf_generation_success_total
pdf_generation_failure_total
notification_success_total
notification_failure_total
outbox_publish_failure_total
Useful histograms: create latency, PDF latency, time from finalize to GENERATED.
Scale horizontally by adding API pods (they contend only on the year counter and unique order_id) and adding PDF workers (they contend on the claim update). Shard later by customer_id or month if a single database is no longer enough. Split PDF workers into their own service when render CPU drowns the API. Do not split before the outbox contract is stable.
24. Where logic lives
cmd/server HTTP, worker, publisher wiring
internal/invoice handler, service, repository, model
internal/outbox same-transaction writer; publisher loop
internal/pdf worker, PDFGenerator
internal/storage ObjectStorage
internal/notification worker, NotificationService
internal/orders OrderService port
internal/auth actor and access checks
migrations/
Handlers map errors to HTTP. The service opens the transaction. Repositories do not send email. Tests fake OrderService and PDFGenerator, and use a real database for UNIQUE(order_id) and the PDF claim update.
Local adapters:
ObjectStorage filesystem under ./data/invoices
MessageBroker channel, SQS, or Docker Kafka
NotificationService log + table
PDFGenerator minimal valid PDF bytes
Production adapters implement the same interfaces. Business rules do not change.
25. Final architecture
Client
│
▼
Invoice API
│
▼
Invoice Service
│
└── PostgreSQL
invoices
invoice_items
invoice_number_counters
idempotency_records
outbox_events
PostgreSQL outbox
│
▼
Publisher → Broker
├── PDF Worker → ObjectStorage
│ then outbox InvoicePDFGenerated
└── Notification Worker → Email abstraction
Create:
POST /invoices + Idempotency-Key
→ snapshot order
→ one transaction: number, rows, outbox
→ 201 FINALIZED, pdf PENDING
PDF:
InvoiceCreated → claim PROCESSING → render → Put → GENERATED
Download:
authorize → presigned URL
Notify:
InvoicePDFGenerated → send once
PostgreSQL owns the document. Object storage owns the bytes. The broker moves work. Email proves nothing the database did not already commit.
26. Interview-ready summary
Key decisions to remember
- Scope the interview to issuing a snapshot invoice, async PDF, download, and notify — not a tax ERP.
- Create is synchronous; PDF and email are not.
- Money is copied onto line items and never recomputed from the catalog.
- Finalized invoices are immutable; regenerate only the PDF.
- Invoice, PDF, and notification are separate state machines. PDF failure does not cancel the invoice.
UNIQUE(order_id)plus idempotency keys stop duplicate creates.- Allocate
INV-2026-…with a database counter, not memory. - Write
InvoiceCreatedin the same transaction as the invoice. - PDF workers claim with
PENDING → PROCESSING. - Store bytes in object storage; authorize in the API; return a short-lived URL.
- Consumers are idempotent because delivery is at-least-once.
- Retries back off, then dead-letter; operators replay.
Likely interviewer follow-up questions
- Why not generate the PDF inside
POST /v1/invoices? - Why not
SELECTcurrent product prices when rendering the PDF? - Why three status columns instead of one?
- How do two checkout retries not create two invoices?
- How do two API pods not issue the same invoice number?
- Why an outbox instead of publishing after
COMMIT? - How does a second
InvoiceCreatednot upload two PDFs and send two emails? - Why not store the PDF in PostgreSQL?
- Why pre-signed URLs instead of streaming through the API?
- What does regenerate change, and what must it not change?
- What happens if S3 is down for an hour after a successful create?
- When would you split PDF workers into a separate service?
Senior-level points that differentiate the answer
- Treat the invoice as a legal snapshot, not a view over orders.
- Separate “the document exists” from “the file exists” from “the email was sent.”
- Put uniqueness in constraints and claims in
UPDATE … WHERE status = …. - Keep Kafka, S3, and SMTP out of the create transaction.
- Assume at-least-once everywhere and show the duplicate path.
- Use interfaces so the whiteboard maps to local fakes and later to AWS.
- Monitor finalize-to-PDF lag, not only HTTP QPS.
A 1–2 minute verbal answer
I would scope this to one invoice per paid order, with immutable money, an async PDF, authorized download, and async notification. Create copies line items from Order Service, validates totals, allocates
INV-2026-…from a year counter withSELECT FOR UPDATE, and writes the invoice, items, idempotency row, andInvoiceCreatedoutbox event in one PostgreSQL transaction.UNIQUE(order_id)and the idempotency key make retries safe across multiple pods. The API returns FINALIZED withpdf_status PENDING.A worker claims the PDF with a conditional update, renders through a
PDFGenerator, PUTsinvoices/{year}/{month}/{id}.pdfto object storage, and marksGENERATED. Download authorizes the caller and returns a short-lived URL; the bucket is not public. Notification runs onInvoicePDFGenerated, not on create, and de-duplicates withevent_id. If the renderer, broker, or mailer fails, the invoice stays FINALIZED. We retry with backoff and then dead-letter.
For the broader interview framework around this problem, see the System Design Interview Complete Guide.
