Invoice numbering
How Eziseller assigns human-readable identifiers to tax invoices. Audience: new dev exploring how invoices are labelled on PDFs and in audit logs.
1. Overview
Eziseller does not have a dedicated invoice-numbering subsystem today. There is no Invoice model, no sequence counter, and no invoiceNumber column anywhere in the schema. Every PDF invoice is labelled by reusing the parent Order.orderNumber and prefixing it with the string INV- at render time inside the PDF template. The invoice-number-service.ts file referenced in scratch notes does not exist on disk — it is planned work, not shipped code.
This means the "invoice number" is really just a presentation-layer decoration of the order number. Any uniqueness, monotonicity, or fiscal-year reset behaviour has to be inherited from how order numbers themselves are generated.
2. Architecture
The PDF template is the only place the INV- prefix is introduced. The database stores only the raw order number and a Cloudinary URL pointing at the last-uploaded PDF.
3. Data model
There is no Invoice, InvoiceSequence, InvoiceCounter, or FiscalYear model. See schema.prisma:L402-L422 for Order and schema.prisma:L725-L727 for DraftOrder.invoiceUrl.
4. Key flows
4.1 Order number assignment (the upstream source of truth)
WhatsApp and Instagram flows use their own generators (WA- / IG- prefixes) in order-creation-service.ts:L179 and draft-order-service.ts:L359, which retry on unique-constraint collision.
4.2 Invoice rendering
The download endpoint streams the PDF directly and does not persist invoiceUrl. Persistence only happens through document-service.ts:L12 (generateAndUploadInvoice), which is called from other flows (e.g. messaging) but not from the on-demand download route.
5. Lifecycle / state machine
Invoice "identity" has no independent state — the invoice is recomputed from the order every time.
6. Key files
- backend/routes/orders.ts:L390-L391 — manual
orderNumbergeneration (ORD-${Date.now()}-${count+1}). - backend/lib/order-creation-service.ts:L179-L200 — WhatsApp/Instagram
orderNumberwith collision retry. - backend/lib/draft-order-service.ts:L359-L380 — draft-order variant of the same.
- backend/routes/documents.ts:L24-L147 —
/api/documents/invoice/:orderIddownload + preview. - backend/templates/invoice-template.ts:L100-L103 — the single line that writes
Receipt / Tax Invoice #INV-<orderNumber>onto the PDF. - backend/lib/pdf.ts:L85-L89 —
PDFService.generateInvoicethin wrapper. - backend/lib/document-service.ts:L12-L46 — Cloudinary upload; public_id is
invoice-<orderNumber>-<Date.now()>. - backend/prisma/schema.prisma:L402-L422 —
Orderfields (orderNumber,invoiceUrl).
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
CLOUDINARY_* | yes (for upload path) | Stores generated PDFs under documents/invoices | generateAndUploadInvoice throws; download route still works |
| — | — | No invoice-numbering env vars exist (no prefix, no fiscal-year, no starting-number config) | — |
8. Gotchas & troubleshooting
- No real invoice number: The
INV-string is hard-coded in the template. You cannot configure a per-store prefix, a fiscal-year segment (e.g.INV/2025-26/0001), or a separate series for credit notes. Indian GST requires a monotonic per-FY series — Eziseller does not satisfy this today. - Count-based order numbers are racy:
prisma.order.count({ where: { userId } }) + 1in routes/orders.ts:L390 is not atomic. Two concurrentPOST /api/orderscan both read the same count and both try to insertORD-<ts>-0042. The unique constraint onOrder.orderNumberwill reject one of them and the user gets a 500 — there is no retry loop here (unlike the messaging-side generators). - No uniqueness across stores for the visible number:
orderNumberis unique globally, but the human-facingINV-ORD-<ts>-0042leaks timestamp and per-user count. Two different stores will both see counts starting at 1, so theirINV-strings look similar but are not actually collision-free across the table (the timestamp disambiguates in practice). - Re-generating the PDF does not change the number: Because the "number" is derived, regenerating after fixing a typo produces the same
INV-label — good for idempotency, bad if you need to void-and-reissue with a new legal number. invoiceUrlis stale after re-upload:generateAndUploadInvoiceusespublic_id: invoice-<orderNumber>-<Date.now()>, so each upload creates a new Cloudinary asset. The column is updated by the caller, not by the service; if the caller forgets,Order.invoiceUrlpoints at an older render.- Missing files from git status are not shipped:
backend/lib/invoice-number-service.tsand the20260411171832_add_tax_invoice_systemmigration appear ingit statusas untracked but are not present on disk in the working tree. Do not assume they exist when reading this doc.
9. Extension points
A planned centralised InvoiceNumberService should own:
- A per-store
InvoiceSequencetable keyed by(storeId, fiscalYear, series)with anextNumbercolumn incremented inside a transaction usingSELECT ... FOR UPDATE(or Prisma's$transaction+updatewith version check). - A configurable format string per store (e.g.
{{prefix}}/{{fy}}/{{seq:6}}). - Separate series for invoices, credit notes, and proforma.
- Writing the resolved number to a new
Order.invoiceNumbercolumn (distinct fromorderNumber) so the label is stable even iforderNumberchanges.
Until that lands, treat the INV- label as cosmetic and never rely on it for statutory reporting.
10. Related docs
- tax-engine.md — how line-item tax is computed and shown on the same PDF.
- pdf-generation.md — PDFKit pipeline, image prefetch, template composition.
- ../orders/order-lifecycle.md — where
orderNumberis assigned and mutated.