> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hitaji360.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Document Numbering Internals

> How the accounting service allocates human-readable document numbers (invoice numbers, bill numbers, journal-entry numbers, …) per business, gap-free and…

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

| File                                                                                     | Role                                                                       |
| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `src/accounting/services/document-sequence.service.ts`                                   | The allocator: `nextValue` / `nextFormattedNumber`.                        |
| `src/accounting/entities/document-sequence.entity.ts`                                    | TypeORM entity `DocumentSequence` → table `accounting_document_sequences`. |
| `src/accounting/migrations/1744000000000-AddAccountingSequencesAndLedgerImmutability.ts` | Creates the table, unique constraint and index.                            |

`DocumentSequenceService` is registered in `accounting.module.ts` and injected
into the repositories/services that mint numbers (see [Call sites](#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.

```sql theme={null}
CREATE TABLE "accounting_document_sequences" (
  "id"            uuid NOT NULL DEFAULT uuid_generate_v4(),
  "tenant_id"     uuid NOT NULL,
  "workspace_id"  uuid NOT NULL,
  "sequence_key"  character varying(100) NOT NULL,
  "current_value" bigint NOT NULL DEFAULT 0,
  -- BaseEntity audit columns: date_created, date_updated, created_by_id,
  -- last_updated_by_id, is_deleted, deleted_at
  CONSTRAINT "PK_accounting_document_sequences" PRIMARY KEY ("id"),
  CONSTRAINT "UQ_accounting_document_sequences_scope"
    UNIQUE ("tenant_id", "workspace_id", "sequence_key")
);

CREATE INDEX "IDX_accounting_document_sequences_scope"
  ON "accounting_document_sequences" ("tenant_id", "workspace_id", "sequence_key");
```

The entity carries `currentValue` as `bigint default 0`; the unique index on
`['tenantId', 'workspaceId', 'sequenceKey']` is what makes the upsert below
atomic.

***

## Allocation: `nextValue`

```ts theme={null}
async nextValue(tenantId, workspaceId, sequenceKey): Promise<number>
```

A single SQL statement does an **atomic upsert-and-increment**:

```sql theme={null}
INSERT INTO accounting_document_sequences
  (id, tenant_id, workspace_id, sequence_key, current_value, ...)
VALUES (uuid_generate_v4(), $1, $2, $3, 1, ...)
ON CONFLICT (tenant_id, workspace_id, sequence_key)
DO UPDATE SET current_value = accounting_document_sequences.current_value + 1,
              date_updated = now()
RETURNING current_value;
```

* 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`

```ts theme={null}
async nextFormattedNumber(tenantId, workspaceId, sequenceKey, prefix, date = new Date()): Promise<string>
```

Wraps `nextValue` and formats:

```ts theme={null}
return `${prefix}-${date.getFullYear()}-${String(nextValue).padStart(6, '0')}`;
```

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.

| Document          | Caller                                               | `sequenceKey`        | `prefix`          | Example            |
| ----------------- | ---------------------------------------------------- | -------------------- | ----------------- | ------------------ |
| Invoice           | `invoices.repository.ts` `generateInvoiceNumber`     | `invoices`           | `INV`             | `INV-2026-000001`  |
| Bill              | `bills.repository.ts` `generateBillNumber`           | `bills`              | `BILL`            | `BILL-2026-000001` |
| Expense           | `expenses.repository.ts` `generateReferenceNumber`   | `expenses`           | `EXP`             | `EXP-2026-000001`  |
| Journal entry     | `journal.repository.ts` `generateEntryNumber`        | `journal_entries`    | `JE`              | `JE-2026-000001`   |
| Quotation         | `quotations.repository.ts` `generateQuotationNumber` | `quotations`         | `QTN`             | `QTN-2026-000001`  |
| Bank loan         | `bank-loans.service.ts` `create`                     | `bank-loans`         | `LN`              | `LN-2026-000001`   |
| Payment / Receipt | `payments.repository.ts` `generateReferenceNumber`   | `payments:${prefix}` | caller-supplied   | `RCP-2026-000001`  |
| Petty-cash fund   | `petty-cash.repository.ts` `generateFundNumber`      | `petty_cash_funds`   | — (custom format) | `PCF-0001`         |

### 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.
