Skip to content

Customizable document numbering — design

Status: approved in conversation 2026-09-25, spec under review. Extends: architecture design §20.1.3 (document numbering — a table, not a sequence), §10.2 (journals and their sequences), §11A.4 (traceability), §4.5 (audit). Roadmap: Phase 3, Customizable document numbering (Plan 12).

Every document number is PREFIX-PERIOD-NNNN, and the prefix is a constant in the module that issues it: EC claims, EG claim groups, AD advances, PF / PS / PT / PX petty cash, and each journal’s sequence_key for journal entries. An organization cannot change any of it.

Businesses buying an accounting system expect to. A customer moving from AutoCount or SQL Account arrives mid-year at INV-05020 and expects the next one to be INV-05021, in their own layout. Fixed formats are a sales objection, and invoices (Phase 2) will make it louder.

What already holds and must keep holding (§20.1.3): allocation is an upsert on a locked number_sequence (org_id, scope, period) row inside the document’s own transaction, so concurrent creates serialize and a rollback returns the number. Every document table carries UNIQUE (org_id, reference) (journal entries: UNIQUE (org_id, entry_no)).

Numbered so the plan and reviews can cite them.

  • N1 — One pattern per document type per organization. A pattern is literal text plus placeholders, and a reset rule. Rejected for now: per-branch counters (no branch concept exists yet) and several selectable series per document type (AutoCount’s model; matters mainly for sales documents, Phase 2). The design leaves room for both: a new placeholder and a wider counter key, not a rewrite.

  • N2 — Placeholders. {YYYY}, {YY}, {MM}, {FY}, and {SEQ:n} with n from 3 to 10. {SEQ:n} zero-pads to n digits and grows past n rather than wrapping (as Format does today). Literal text is letters, digits, - / . _. At most 40 characters rendered at the padding width. Placeholders are case-sensitive; anything in braces that is not one of these is refused.

  • N3 — Exactly one {SEQ:n}.

  • N4 — Reset rules and what each requires. The pattern must contain what makes a number unique across periods:

    Reset Counter period key Pattern must contain
    yearly (calendar) 2026 {YYYY} or {YY}
    monthly 202609 {YYYY} or {YY}, and {MM}
    fiscal_yearly fiscal year’s end year, 2027 {FY}
    never 0 nothing further

    One checker (numbering.Validate) serves the API, the preview and the tests.

  • N5 — {FY} and fiscal_yearly are for journals only. A claim must be creatable before a fiscal year exists (§20.1.3), so a business document cannot depend on one. Business documents keep the calendar (§20.1.3, human-facing references use the calendar year).

  • N6 — Journals are customizable, with guardrails. Reset is always fiscal_yearly and the pattern must contain {FY}. Journal entry numbers stay gap-free per fiscal year (§20.1.3, §11A.4); only their layout changes. Each journal is its own document type, keyed by its existing sequence_key, because each already has its own counter.

  • N7 — Changing the pattern never resets the counter. A new pattern applies from the next document issued. The counter row is keyed by scope and period, not by pattern, so within a period every number is still issued once.

  • N8 — A change that alters the reset rule takes effect on 1 January of the next year, pattern and rule together. Switching mid-year (yearly to monthly, or the reverse) would start new counters for periods that already issued numbers under the old rule and could repeat them. The whole change is stored as pending with its effective date, and the current pattern and rule keep applying until then; the screen shows what is scheduled. A change that keeps the rule applies immediately (N7). Saving again replaces a pending change; reverting to the current setting cancels it.

  • N9 — The starting number can be set once per document type and period, before any number is issued. That is: only when no number_sequence row exists for (org, scope, current period). This covers migration from another system without ever allowing a skip or a repeat later. Raising the counter after issuing is refused, journals and business documents alike.

  • N10 — No configuration means today’s numbers exactly. The built-in default for each document type is its current prefix with {prefix}-{YYYY}-{SEQ:4}, yearly (journals: {sequence_key}-{FY}-{SEQ:4}, fiscal_yearly). An organization that never opens the screen sees no change. Reverting to the default is a change like any other: immediate if the rule is unchanged, scheduled per N8 if not.

  • N11 — Numbers already issued never change. They are printed, emailed and cited in the audit trail. Nothing here rewrites a stored reference.

  • N12 — The database remains the backstop. N4 and N7 make repeats impossible within one pattern. Across a pattern change, two different patterns could in principle render the same text for different periods; the existing unique constraints refuse that insert, and the create returns a 409 saying the numbering format produced a number already in use. The screen’s preview warns when a proposed pattern renders a number that already exists.

  • N13 — One permission, org.numbering.manage. Granted to Owner at provisioning. It covers reading and changing formats, journals included. Every change is audited with the pattern and rule before and after.

  • N14 — All call sites go through one function. numbering.Issue allocates and renders together, so a module cannot format a number its own way. A drift test enforces it.

New table, migration 00210_numbering_format.sql (Plan 11 reserves 00190–00209):

CREATE TABLE numbering_format (
org_id UUID NOT NULL REFERENCES organization(id),
scope TEXT NOT NULL,
pattern TEXT NOT NULL,
reset TEXT NOT NULL CHECK (reset IN ('yearly','monthly','fiscal_yearly','never')),
pending_pattern TEXT,
pending_reset TEXT CHECK (pending_reset IN ('yearly','monthly','fiscal_yearly','never')),
pending_from DATE, -- always a 1 January (N8)
updated_by_member_id UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (org_id, scope),
CHECK ((pending_pattern IS NULL) = (pending_reset IS NULL)
AND (pending_reset IS NULL) = (pending_from IS NULL))
);
-- ENABLE + FORCE ROW LEVEL SECURITY, policy org_isolation (project invariant).

number_sequence is unchanged; only the period value it is given depends on the rule (N4). scope values are today’s (expense_claim, advance, journal:GJ, …), so existing counters carry on.

A pending change is applied lazily: Issue and the read endpoint treat a row whose pending_from <= today as already switched, and the next write folds it in. No scheduled job. Reverting to the default (N10) is stored as the default’s pattern and rule, not by deleting the row, so a scheduled revert has somewhere to live; is_default is computed by comparing with the registered default.

platform/numbering gains:

// Scope is a numbered document type. Modules declare theirs once:
// var claimScope = numbering.Register("expense_claim", "EC", "numbering.scope.expense_claim")
func Register(key, defaultPrefix, labelKey string) Scope
func JournalScope(sequenceKey string) Scope
// When is the moment a number is issued for: the document's date, and — for journal
// scopes only — the fiscal year's end year. Business scopes leave FiscalEndYear zero.
type When struct { Date time.Time; FiscalEndYear int }
// Issue allocates and renders in the caller's transaction.
func Issue(ctx context.Context, tx pgx.Tx, s Scope, w When) (string, error)
func Validate(s Scope, pattern string, reset Reset) error // N2–N6
func Render(pattern string, w When, n int64) (string, error) // pure

Allocate becomes unexported. The ten call sites change from Allocate + Format to one Issue call. numbering stays below the modules and imports nothing from accounting: the ledger passes the fiscal year in When.

Declared in api/openapi.yaml first (the contract).

  • GET /org/numbering — every registered scope plus one per journal: key, label key, pattern, reset, is_default, any pending change with its date, the next number for the current period, and a rendered preview.
  • PUT /org/numbering/{scope} — {pattern, reset}. Validated per N2–N6; applied now or scheduled per N7–N8, and the response says which. A refusal names the rule, e.g. “a monthly reset needs {MM} and a year placeholder”.
  • POST /org/numbering/{scope}/start — {next_number}. Refused per N9.
  • DELETE /org/numbering/{scope} — back to the default (N10), subject to N8.
  • POST /org/numbering/preview — {scope, pattern, reset} → rendered sample and any validation error, including N12’s already-in-use warning. Saves nothing.

All behind httpx.Authorize("org.numbering.manage").

Document numbering, in the admin section beside Workflows.

  • Table: document type, pattern, reset, next number sample, Edit.
  • Edit panel: pattern field with clickable placeholder chips, reset selector (journals show only fiscal year), live preview, a pending-change notice, and Starting number shown only while N9 allows it.
  • Every label is a translation key (en, ms, zh).

Every absence test first shows the presence (AGENTS.md), and every guard is watched failing once.

Unit (numbering, no database): each placeholder renders, {SEQ:n} pads and grows; Validate refuses each bad input (two {SEQ}, unknown placeholder, bad character, too long, each missing N4 requirement, {FY} on a business scope, a non-fiscal reset on a journal), each paired with a near-identical input that passes.

Database (testdb.Start):

  • No configuration → EC-2026-0001 and today’s format for every registered scope (N10).
  • A custom pattern changes the next number; the counter continues across the change (N7).
  • Monthly counters are independent per month; fiscal-yearly follows FiscalEndYear.
  • A reset change is not applied before 1 January and is applied after it (test clock); a pattern-only change applies at once; saving the current setting cancels a pending one.
  • A starting number is accepted on an empty period, then refused once one number is issued.
  • Rollback returns the number; concurrent issues do not collide (existing tests move to Issue).
  • Org B cannot read or change org A’s formats, after showing org A can read its own.
  • N12: a pattern change that would repeat an existing reference yields the 409, not a duplicate.

Drift:

  1. numbering_format falls under every table is tenant-isolated.
  2. No package outside numbering allocates or formats a number itself: the guard counts a non-zero number of Issue call sites and zero bypasses.
  3. Every scope passed to Issue is registered, so it appears on the screen.

End to end: as owner, set expense claims to CLM/{YY}{MM}/{SEQ:4} (yearly, so it applies at once); as employee, create a claim; its reference matches CLM/<yy><mm>/<4+ digits> and the screen’s preview had shown the same number. The starting number is covered by the database tests, because a live dev org has usually issued numbers this year already (N9).

Per-branch counters, multiple series per document type, {DD} and daily reset, renumbering issued documents, and number formats for documents that do not exist yet (they register a scope when they are built).