Where it lives
DocumentSequenceService is registered in accounting.module.ts and injected
into the repositories/services that mint numbers (see Call sites).
The storage model
One row per(tenant_id, workspace_id, sequence_key) tuple holds the last value
handed out for that series. workspace_id here is the accounting book id
(see the repo-wide “Workspace ID duality” note), so every book gets its own
independent counter.
currentValue as bigint default 0; the unique index on
['tenantId', 'workspaceId', 'sequenceKey'] is what makes the upsert below
atomic.
Allocation: nextValue
- First allocation for a tuple inserts
current_value = 1. - Every subsequent allocation hits the
ON CONFLICTbranch and increments by 1. - The statement
RETURNING current_value, so the caller gets the freshly allocated number in one round-trip. - If the returned value isn’t a number (driver/SQL anomaly), the service throws
with the offending
sequenceKey, tenant and workspace so the failure is diagnosable.
Concurrency & gap-freeness
The design comment in the service spells out the contract:Gap-free by design: row-level lock serializes concurrent callers per sequence key. PG sequences would be faster but allow gaps, which breaks regulatory compliance for invoices/bills.The
ON CONFLICT DO UPDATE takes a row-level lock on the conflicting row for
the duration of the transaction. Concurrent allocators for the same tuple
serialize on that lock and each observes a distinct, contiguous value — there
are no gaps and no duplicates. Allocators for different tuples (different book,
or different series) don’t contend.
This is deliberately not a Postgres SEQUENCE: native sequences are faster
but burn numbers on rollback, producing gaps that are unacceptable for
statutory documents (invoices, bills). Here the counter only advances inside the
committing transaction’s view because it is an ordinary row update.
Caveat for callers: because the increment is part of the surrounding transaction, allocating a number and then rolling the transaction back will not release the number unless the allocation itself is rolled back with it. Numbers are intended to be allocated at the point the document is persisted.
Formatting: nextFormattedNumber
nextValue and formats:
PREFIX-YYYY-NNNNNN — prefix, calendar year of
the supplied date (defaults to now), then the raw counter zero-padded to 6
digits.
Two things to note:
- The counter is monotonic across years — it is not reset on January 1.
The year in the string comes from
date.getFullYear(), butcurrent_valuekeeps climbing.INV-2025-000412can be followed byINV-2026-000413. getFullYear()is local-time, unlike the UTC date helpers used elsewhere in the reports service. Numbering is not timezone-critical, but be aware of the inconsistency if you ever reconcile a number’s year against a UTC posting date.
Call sites
Each numbered document type owns a distinctsequenceKey. Because the key is
part of the unique tuple, every type counts independently within a book.
Two non-standard cases
- Payments namespace their key by prefix:
payments:${prefix}. This means each payment prefix (e.g.PAY,RCP) gets its own counter, so receipts and outbound payments don’t share a running number even though they live in the same repository. - Petty-cash funds bypass
nextFormattedNumberand callnextValuedirectly, then format asPCF-+ the value zero-padded to 4 digits with no year segment (PCF-0001). If you need petty-cash fund numbers to match the standardPREFIX-YYYY-NNNNNNshape, this is the call site to change.
Adding a new numbered document type
- Pick a unique
sequenceKeystring (≤ 100 chars) that no other document type uses — it is the third leg of the unique tuple. - Inject
DocumentSequenceServiceinto the repository/service that creates the document. - Call
nextFormattedNumber(tenantId, workspaceId, '<your-key>', '<PREFIX>')at the point you persist the row, inside the same transaction. - No migration is needed — the table already exists and rows are created
lazily on first allocation via the
INSERT ... ON CONFLICTupsert.