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).
1. Problem
Section titled “1. Problem”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)).
2. Decisions
Section titled “2. Decisions”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}withnfrom 3 to 10.{SEQ:n}zero-pads tondigits and grows pastnrather than wrapping (asFormatdoes 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}monthly202609{YYYY}or{YY}, and{MM}fiscal_yearlyfiscal year’s end year, 2027{FY}never0nothing further One checker (
numbering.Validate) serves the API, the preview and the tests. -
N5 —
{FY}andfiscal_yearlyare 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_yearlyand 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 existingsequence_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_sequencerow 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
409saying 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.Issueallocates and renders together, so a module cannot format a number its own way. A drift test enforces it.
3. Data
Section titled “3. Data”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.
4. Code
Section titled “4. Code”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) Scopefunc 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–N6func Render(pattern string, w When, n int64) (string, error) // pureAllocate 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.
5. API
Section titled “5. API”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").
6. Screen
Section titled “6. Screen”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).
7. Testing
Section titled “7. Testing”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-0001and 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:
numbering_formatfalls under every table is tenant-isolated.- No package outside
numberingallocates or formats a number itself: the guard counts a non-zero number ofIssuecall sites and zero bypasses. - Every scope passed to
Issueis 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).
8. Out of scope
Section titled “8. Out of scope”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).