Skip to main content
How the accounting service allocates human-readable document numbers (invoice numbers, bill numbers, journal-entry numbers, …) per business, gap-free and collision-free under concurrency. Audience: engineers adding a new numbered document type or debugging a duplicate / out-of-sequence number.

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.
The entity carries currentValue as bigint default 0; the unique index on ['tenantId', 'workspaceId', 'sequenceKey'] is what makes the upsert below atomic.

Allocation: nextValue

A single SQL statement does an atomic upsert-and-increment:
  • First allocation for a tuple inserts current_value = 1.
  • Every subsequent allocation hits the ON CONFLICT branch 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

Wraps nextValue and formats:
So the canonical format is 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(), but current_value keeps climbing. INV-2025-000412 can be followed by INV-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 distinct sequenceKey. 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 nextFormattedNumber and call nextValue directly, then format as PCF- + the value zero-padded to 4 digits with no year segment (PCF-0001). If you need petty-cash fund numbers to match the standard PREFIX-YYYY-NNNNNN shape, this is the call site to change.

Adding a new numbered document type

  1. Pick a unique sequenceKey string (≤ 100 chars) that no other document type uses — it is the third leg of the unique tuple.
  2. Inject DocumentSequenceService into the repository/service that creates the document.
  3. Call nextFormattedNumber(tenantId, workspaceId, '<your-key>', '<PREFIX>') at the point you persist the row, inside the same transaction.
  4. No migration is needed — the table already exists and rows are created lazily on first allocation via the INSERT ... ON CONFLICT upsert.
No schema change is required per type because the counter rows are self-provisioning.