Plan 12 — Customizable document numbering Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Each organization sets its own number layout per document type — a pattern of literal text and placeholders plus a reset rule, and a one-time starting number for migration — while allocation stays gap-free, journals keep a fiscal-year reset, and an organization that configures nothing gets exactly today’s numbers.
Architecture: internal/platform/numbering grows from “allocate + format” into the one place a number is issued: a registry of Scopes declared by the modules that own each document, a pure pattern checker/renderer, a numbering_format table holding each organization’s pattern, rule and any scheduled rule change, and Issue, which reads the effective format, derives the counter period from the rule, allocates on the existing locked number_sequence row and renders — all in the caller’s transaction. The ten call sites move to Issue. A new module internal/modules/numbering serves /org/numbering behind org.numbering.manage, and the web app gains a Document numbering admin page.
Tech Stack: Go 1.26 · Gin · pgx v5 · goose · oapi-codegen · testify · testcontainers Postgres 17 · React · TanStack Query/Router · Radix UI · react-hook-form + zod · i18next (en/ms/zh) · Vitest + MSW · Playwright
Spec: docs/superpowers/specs/2026-09-25-document-numbering-design.md (decisions N1–N14), extending 2026-09-20-erp-architecture-design.md §20.1.3, §10.2, §11A.4. Binding project rules: AGENTS.md.
Global Constraints
Section titled “Global Constraints”- Allocation stays gap-free (§20.1.3): the number is taken on the locked
number_sequence (org_id, scope, period)row inside the document’s own transaction; a rollback returns it. No Postgres sequence, ever. - No configuration means today’s numbers exactly (N10):
EC-2026-0001,EG-…,AD-…,PF-…,PS-…,PT-…,PX-…, and<sequence_key>-<FY end year>-NNNNfor journals. Existingscopestrings and counters are reused unchanged. - Every new business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, and a policy named exactlyorg_isolation(internal/kernel/db/rlscoverage_test.godiscovers tables automatically). - Every constraint is named (
internal/kernel/db/constraintnames_test.go): no name ending_check,_keyor_excl. - Every tenant query carries an explicit
org_id = $npredicate, even though RLS would filter anyway. - Migrations for this plan are numbered
01100–01119(rebased 2026-10-05:mainis at01060; debit notes will renumber to1070+), registered ininternal/kernel/db/migrationranges_test.goin the same commit as the first migration. Any00210/00211left in task text means01100/01101. numbering_formatgoes in theamendablelist ofinternal/kernel/db/retention_test.go(UPDATE yes, DELETE revoked). Reverting to the default is an UPDATE, never a DELETE.- Vocabulary values are strings: resets
yearly,monthly,fiscal_yearly,never; refusal codes as listed in Task 1. - Every request runs in a transaction, reads included: handlers use
h.deps.DB.InTx; errors are recorded with_ = c.Error(err), never written by hand. - Every route goes through
httpx.Protectedand is pinned in the module’sroutes_test.go. Endpoints are declared inapi/openapi.yamlfirst; generated code is regenerated withmake generate, never hand-edited. - Every user-facing string is an i18n key in
en,msandzh.msis statutory; do not leave it English. - Establish presence before asserting absence (AGENTS.md). Every test that asserts something is refused, missing or not counted first shows it would be allowed, present or counted without the mechanism. Watch each new guard fail once by breaking what it guards, and read the message it prints.
- Test runs: while iterating, only the packages you touched (
go test ./internal/platform/numbering/... -count=1,pnpm -C web test -- <path>). The one full run happens once, in Task 6. - Runtime check for the screen: Task 5 loads the page against the dev stack at 390 px and 1280 px, light and dark, in
enandms, before it reports done. - Commits end with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. Never push; merging is the user’s call.
Rulings
Section titled “Rulings”Made while planning, against the spec. Format: decision — why — cost if wrong.
- R1 — A new module,
internal/modules/numbering, not routes added toidentityororgstructure. — The setting is its own concern with its own permission;internal/app/routes_test.go’scomposedModuleskeys must equal the module directories, so it also gets an entry there. — One more module directory. - R2 —
Issuetakes anumbering.Scopevalue, not a string. Modules obtain theirs fromnumbering.Register(package-levelvar) ornumbering.JournalScope. — An unregistered scope then cannot reachIssuewithout constructingnumbering.Scope{…}by hand, which the Task 3 drift guard forbids outside the package. That is spec drift test 3 enforced by the type plus one scan. — None known. - R3 — The spec’s create-time
409(N12) is the existing unique-violation Problem, not a new message.httpx.Errorsrenders only an unwrapped*apperr.Problem, and every call site wraps errors withfmt.Errorf("…: %w"); a numbering-specific Problem fromIssuewould reach the client as a 500. The constraint’s 409 ("a record violating expense_claim_reference_once already exists") is what the client already gets. The preventive half of N12 is strengthened instead:PUTrefuses (409, codenumber_in_use) a pattern whose next number already exists, and the preview reportsin_use. — Keeps the rendering path untouched. — A future-period collision after an unusual pattern change still surfaces with the generic 409 wording. - R4 — Each scope knows its document table and reference column (
Register(key, prefix, table, column)), for the in-use check. Journals sharejournal_entry.entry_no. The identifiers are compile-time constants, never request input. — The check needs them and the owning module is the one place that knows them. — None known. - R5 — Two journals may not hold the same pattern.
journal_entry.entry_nois unique across all journals of an organization, so two journals rendering the same pattern would collide on their first shared number.PUTrefuses it with codepattern_shared_with_journal. Near-collisions between different patterns remain possible in contrived cases and are caught by the in-use check and the unique constraint. — Cheap, catches the realistic mistake (copying one journal’s pattern to another). — None known. - R6 — “Today” is
clock.TodayUTC, as at every existing call site. The standing open decision about UTC vs Asia/Kuala_Lumpur is not reopened here. — Consistency; changing it is a separate decision. — A document created 00:00–08:00 MYT on 1 January numbers into the old year, as today. - R7 — The starting number (N9) for a journal needs a fiscal year containing today; without one,
POST …/startis refused422 no_fiscal_year. The preview of a journal pattern with no fiscal year uses today’s calendar year as the sample{FY}. — A journal’s counter period is the fiscal year. — None known. - R8 —
DELETE /org/numbering/{scope}reverts by UPDATE (an upsert of the default), as the spec’s §3 says; the row is never deleted. —amendableretention. — None.
Rulings added 2026-10-05 after rebasing onto main 43e3e6ff (they override task text below)
Section titled “Rulings added 2026-10-05 after rebasing onto main 43e3e6ff (they override task text below)”The tree grew from 10 call sites to 25 (21 files, ~30 scopes) since this plan was written. Inventory, with file:line, was taken from main; where a ruling below conflicts with a task, the ruling wins.
-
R9 — Scopes have a basis, Calendar or Fiscal. Amends N5. N5 assumed every business document is calendar-year. Not so: sales/purchase invoices, credit notes, receipts, payments, payment batches, WHT remittances and tax-return payments already number by
year.EndYear(they need a fiscal year to post at all), andINV-2027-0001means fiscal 2027. N10 (no configuration = today’s numbers) therefore requires those scopes to default to{prefix}-{FY}-{SEQ:4},fiscal_yearly, and to allow{FY}andfiscal_yearlylike journals do. Calendar scopes (claims, groups, advances, petty cash spend/top-up/closeout, trade documents, fixed assets) keep N5. API:Register(key, prefix, table, column)is calendar;RegisterFiscal(...)is fiscal;Validatetakes the basis from the scope.When.FiscalEndYearis set by fiscal scopes’ call sites (they already holdyear). — Keeps issued numbers continuing across the change. — N5’s rationale (claims before a fiscal year exists) still holds for calendar scopes. -
R10 — Fixed scopes (
RegisterFixed): issued throughIssue, so the bypass guard still holds, but not editable and not listed on the screen:payroll_run(00900CHECKspayroll_run.reference ~ '^PR-[0-9]{4}-[0-9]{4,}$'and the payslip reference derived from it,run.go:437);petty_cash_float(its code is spliced into the chart account code, ≤32 chars,00050:15); and the threeopening:*kinds on each side (OB-marks migrated documents). Payroll’s year is the payroll month’s calendar year:When{Date: month}. Unfixing payroll needs a migration relaxing both CHECKs; out of scope. -
R11 — Debit notes (
DN,SDN) are on branchdebit-notes, unmerged, and add entries to the twoprefixesmaps. Task 3 convertsmainas it is. When debit notes merge, they registerRegisterFiscal("sales_debit_note","DN",…)/("purchase_debit_note"… "SDN"); the Task 3 drift test (everyIssuescope registered) fails until they do, which is the point. Merge order: debit notes first, if possible. -
R12 — Reference column is not always
reference.trade_document.numberandasset.number(andpetty_cash_float.code). R4’scolumnargument covers it. -
R13 — Sales invoices and receipts are numbered at issue/approval by UPDATE (
receivables/transition.go:273,334), their draft reference is NULL.Issueis called in the same place; the N12 in-use check reads the same column. -
R14 — Separators are optional. Literal text may be empty;
INV{YYYY}{MM}{SEQ:5}and{SEQ:6}are valid. Only{SEQ:n}varies in width (N3), so a dividerless number decodes uniquely. The editor offers dividerless presets beside the dashed ones. Pinned byTestPatternsWithoutDividers(Task 1). -
R15 — Fixed is one flag, so unfixing is one line. R9 and R10 confirmed by the user 2026-10-05, with the condition that any fixed scope is easy to make customizable later. Therefore
Scopecarries a singleCustomizable bool;RegisterFixedisRegisterwith it false, and nothing else branches on which scopes are fixed: the API, the screen,ValidateandIssueread the flag only. Unfixing a scope is changing its registration call fromRegisterFixedtoRegister/RegisterFiscal, plus, for payroll alone, a migration relaxing the two00900CHECKs. No fixed scope may be named anywhere else in the numbering package or module. Pinned by a test that registers the same key fixed and customizable and shows the screen listing andPUTdiffer only by that flag (presence first: the customizable one is listed and acceptsPUT).
Task 3 call sites beyond the first ten (file:line of Allocate; scope → prefix; basis):
receivables/transition.go:252 sales_invoice/customer_credit_note → INV/CRN, fiscal (sales_invoice.reference); :330 customer_receipt → RCT, fiscal; receivables/opening.go:271 fixed opening:* → OB-INV/OB-CRN/OB-RCT; payables/invoice.go:610 purchase_invoice/supplier_credit_note → PI/CN, fiscal; payables/payment.go:327 supplier_payment → SP, fiscal; payables/batch.go:614 payment_batch → PB, fiscal; payables/remittance.go:374 wht_remittance → WR, fiscal; payables/opening.go:134 fixed opening:purchase_invoice/opening:supplier_credit_note → OB-PI/OB-CN; taxreturn/payment.go:275 tax_return_payment → TXP, fiscal; trade/document.go:490 nine kinds → PR/PO/GRN/PRN/QT/SO/DO/SRN/ADJ, calendar (trade_document.number); assets/asset.go:347 fixed_asset → FA, calendar (asset.number); payroll/run.go:203 fixed payroll_run → PR. Dynamic scopes (in.Kind, "opening:"+kind) become a package-level map from kind to Scope, registered at init.
Two prefixes read alike (PR trade requisition vs payroll run) in different columns; no collision. printing/sample.go:17 hardcodes INV-2026-0001 as a layout sample: leave it. No Go code parses a stored reference back (checked).
File Structure
Section titled “File Structure”internal/platform/numbering/pattern.go (T1) new: Reset, placeholders, Validate, Render, PatternErrorinternal/platform/numbering/pattern_test.go (T1) new: pure unit testsinternal/platform/numbering/scope.go (T1) new: Scope, Register, JournalScope, Lookup, Registeredinternal/platform/numbering/scope_test.go (T1) newdb/migrations/01100_numbering_format.sql (T2) newinternal/kernel/db/migrationranges_test.go (T2) + {1100, 1119, "Plan 12 document numbering"}internal/kernel/db/retention_test.go (T2) + numbering_format in amendableinternal/platform/numbering/format.go (T2) new: Setting, Effective, Set, SetStart, Peek, InUseinternal/platform/numbering/numbering.go (T2) + Issue; (T3) Allocate→allocate, Format removedinternal/platform/numbering/numbering_test.go (T2) rewritten onto Issueinternal/platform/numbering/format_test.go (T2) new: DB testsinternal/platform/expenses/claim.go, group.go (T3) Issueinternal/platform/advances/advance.go (T3) Issueinternal/platform/pettycash/{float,spend,topup,replenish,closeout}.go (T3) Issueinternal/platform/accounting/ledger/{post,reverse}.go (T3) Issueinternal/platform/numbering/bypass_test.go (T3) new: drift guardinternal/app/numbering_scopes_test.go (T3) new: the registered setapi/openapi.yaml (T4) + 5 operations, schemasinternal/gen/api/api.gen.go, web/src/lib/api/schema.d.ts (T4) regeneratedconstants.yaml (+ generated vocab) (T4) + org.numbering.managedb/migrations/01101_org_numbering_manage.sql (T4) new: grant to existing Ownersinternal/modules/numbering/{permissions,routes,handlers,errors}.go (T4) newinternal/modules/numbering/{routes_test,handlers_test}.go (T4) newinternal/app/app.go, internal/app/routes_test.go, cmd/api/main.go (T4) wiringweb/src/features/numbering/{api.ts,routes.tsx,NumberingPage.tsx,FormatEditor.tsx} (T5) newweb/src/features/numbering/{NumberingPage.test.tsx,FormatEditor.test.tsx} (T5) newweb/src/lib/api/types.ts (T5) + aliasesweb/src/app/shell/nav.ts, web/src/app/routes.ts (T5) + entryweb/src/lib/i18n/locales/{en,ms,zh}/numbering.json (T5) newweb/e2e/numbering.spec.ts (T6) newdocs/ROADMAP.md, docs/DEPLOY_NOTES.md (T6)Task 1: Pattern language and scope registry (pure)
Section titled “Task 1: Pattern language and scope registry (pure)”Everything a pattern can be, checked and rendered without a database.
Files:
- Create:
internal/platform/numbering/pattern.go,internal/platform/numbering/pattern_test.go - Create:
internal/platform/numbering/scope.go,internal/platform/numbering/scope_test.go
Interfaces:
-
Consumes: nothing new.
-
Produces:
type Reset stringconst (Yearly Reset = "yearly"; Monthly Reset = "monthly"; FiscalYearly Reset = "fiscal_yearly"; Never Reset = "never")func ParseReset(s string) (Reset, bool)type When struct { Date time.Time; FiscalEndYear int } // FiscalEndYear only for journal scopestype PatternError struct { Code, Detail string } // implements errorfunc Validate(s Scope, pattern string, reset Reset) error // nil or *PatternErrorfunc Render(pattern string, w When, n int64) (string, error)func periodKey(r Reset, w When) (int, error) // unexported, used by Task 2type Scope struct {Key, DefaultPattern stringDefaultReset ResetJournal booltable, column string // R4, unexported}func Register(key, prefix, table, column string) Scope // panics on duplicate key or bad identifiersfunc JournalScope(sequenceKey string) Scopefunc Lookup(key string) (Scope, bool) // registered scopes onlyfunc Registered() []Scope // sorted by Keyfunc unregisterForTest(key string) // test-only helper in export_test.goRefusal codes (strings, stable, the web maps them):
pattern_empty,pattern_bad_character,pattern_unknown_placeholder,pattern_seq_count,pattern_seq_width,pattern_too_long,pattern_needs_year,pattern_needs_month,pattern_needs_fy,pattern_fy_journal_only,reset_journal_fixed,reset_fiscal_journal_only,reset_unknown. -
Step 1: Write the failing pattern tests
internal/platform/numbering/pattern_test.go:
package numbering
import ( "errors" "strings" "testing" "time"
"github.com/stretchr/testify/require")
var sept25 = When{Date: time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)}
var claimScope = Scope{Key: "t_claim", DefaultPattern: "EC-{YYYY}-{SEQ:4}", DefaultReset: Yearly}var gjScope = Scope{Key: "journal:GJ", DefaultPattern: "GJ-{FY}-{SEQ:4}", DefaultReset: FiscalYearly, Journal: true}
func TestRenderEachPlaceholder(t *testing.T) { w := When{Date: sept25.Date, FiscalEndYear: 2027} cases := map[string]string{ "EC-{YYYY}-{SEQ:4}": "EC-2026-0033", "CLM/{YY}{MM}/{SEQ:5}": "CLM/2609/00033", "JV/{FY}/{SEQ:3}": "JV/2027/033", "INV{SEQ:6}": "INV000033", "a.b_c-{YYYY}.{SEQ:3}": "a.b_c-2026.033", } for pattern, want := range cases { got, err := Render(pattern, w, 33) require.NoError(t, err, pattern) require.Equal(t, want, got, pattern) }}
func TestRenderSeqGrowsPastItsWidth(t *testing.T) { got, err := Render("EC-{YYYY}-{SEQ:4}", sept25, 10000) require.NoError(t, err) require.Equal(t, "EC-2026-10000", got)}
func TestRenderIsTodaysFormatForTheDefault(t *testing.T) { // N10: the default pattern reproduces the old Format("EC", 2026, 33). got, err := Render(claimScope.DefaultPattern, sept25, 33) require.NoError(t, err) require.Equal(t, "EC-2026-0033", got)}
func code(err error) string { var pe *PatternError if errors.As(err, &pe) { return pe.Code } return ""}
// Each refusal is paired with a near-identical pattern that passes, so the// refusal is shown to come from the rule and not from a broken fixture.func TestValidateRefusalsEachWithAPassingTwin(t *testing.T) { cases := []struct { name string s Scope bad, good string badR, goodR Reset code string }{ {"empty", claimScope, "", "EC-{YYYY}-{SEQ:4}", Yearly, Yearly, "pattern_empty"}, {"bad character", claimScope, "EC {YYYY}-{SEQ:4}", "EC_{YYYY}-{SEQ:4}", Yearly, Yearly, "pattern_bad_character"}, {"unknown placeholder", claimScope, "EC-{yyyy}-{SEQ:4}", "EC-{YYYY}-{SEQ:4}", Yearly, Yearly, "pattern_unknown_placeholder"}, {"unclosed brace", claimScope, "EC-{YYYY-{SEQ:4}", "EC-{YYYY}-{SEQ:4}", Yearly, Yearly, "pattern_unknown_placeholder"}, {"no seq", claimScope, "EC-{YYYY}", "EC-{YYYY}-{SEQ:4}", Yearly, Yearly, "pattern_seq_count"}, {"two seq", claimScope, "{SEQ:3}-{YYYY}-{SEQ:4}", "X-{YYYY}-{SEQ:4}", Yearly, Yearly, "pattern_seq_count"}, {"seq too narrow", claimScope, "EC-{YYYY}-{SEQ:2}", "EC-{YYYY}-{SEQ:3}", Yearly, Yearly, "pattern_seq_width"}, {"seq too wide", claimScope, "EC-{YYYY}-{SEQ:11}", "EC-{YYYY}-{SEQ:10}", Yearly, Yearly, "pattern_seq_width"}, {"too long", claimScope, strings.Repeat("A", 32) + "-{SEQ:4}", strings.Repeat("A", 31) + "-{SEQ:4}", Never, Never, "pattern_too_long"}, {"yearly needs a year", claimScope, "EC-{SEQ:4}", "EC-{YY}-{SEQ:4}", Yearly, Yearly, "pattern_needs_year"}, {"monthly needs a year", claimScope, "EC-{MM}-{SEQ:4}", "EC-{YY}{MM}-{SEQ:4}", Monthly, Monthly, "pattern_needs_year"}, {"monthly needs a month", claimScope, "EC-{YYYY}-{SEQ:4}", "EC-{YYYY}{MM}-{SEQ:4}", Monthly, Monthly, "pattern_needs_month"}, {"FY is journal only", claimScope, "EC-{FY}-{SEQ:4}", "EC-{YYYY}-{SEQ:4}", Yearly, Yearly, "pattern_fy_journal_only"}, {"fiscal reset is journal only", claimScope, "EC-{YYYY}-{SEQ:4}", "EC-{YYYY}-{SEQ:4}", FiscalYearly, Yearly, "reset_fiscal_journal_only"}, {"journal reset is fixed", gjScope, "GJ-{FY}-{SEQ:4}", "GJ-{FY}-{SEQ:4}", Yearly, FiscalYearly, "reset_journal_fixed"}, {"journal needs FY", gjScope, "GJ-{YYYY}-{SEQ:4}", "GJ-{FY}-{SEQ:4}", FiscalYearly, FiscalYearly, "pattern_needs_fy"}, {"unknown reset", claimScope, "EC-{YYYY}-{SEQ:4}", "EC-{YYYY}-{SEQ:4}", Reset("daily"), Yearly, "reset_unknown"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { require.NoError(t, Validate(c.s, c.good, c.goodR), "PRESENCE: the twin passes") require.Equal(t, c.code, code(Validate(c.s, c.bad, c.badR))) }) }}
func TestValidateNeverNeedsNoDate(t *testing.T) { require.NoError(t, Validate(claimScope, "INV-{SEQ:6}", Never))}
// R14: no separator is required anywhere. SEQ is the only variable-width piece// (N3 allows one), so a dividerless number still decodes uniquely.func TestPatternsWithoutDividers(t *testing.T) { for _, c := range []struct { pattern, want string reset Reset }{ {"INV{YYYY}{MM}{SEQ:5}", "INV20260900042", Monthly}, {"{YY}{MM}{SEQ:4}", "26090042", Monthly}, {"{SEQ:6}", "000042", Never}, } { require.NoError(t, Validate(claimScope, c.pattern, c.reset), c.pattern) got, err := Render(c.pattern, sept25, 42) require.NoError(t, err) require.Equal(t, c.want, got, c.pattern) } // Growth stays unambiguous: the fixed-width year/month cannot be confused with SEQ digits. a, _ := Render("{YY}{MM}{SEQ:4}", sept25, 10000) b, _ := Render("{YY}{MM}{SEQ:4}", sept25, 1000) require.NotEqual(t, a, b) require.Error(t, Validate(claimScope, "{YYYY}{SEQ:4}", Monthly), "PRESENCE of the N4 rule: a monthly reset still needs {MM}")}
func TestPeriodKey(t *testing.T) { w := When{Date: sept25.Date, FiscalEndYear: 2027} for r, want := range map[Reset]int{Yearly: 2026, Monthly: 202609, FiscalYearly: 2027, Never: 0} { got, err := periodKey(r, w) require.NoError(t, err) require.Equal(t, want, got, r) } _, err := periodKey(FiscalYearly, sept25) require.Error(t, err, "a fiscal-yearly key without a fiscal year is a caller bug")}- Step 2: Run to verify it fails
Run: go test ./internal/platform/numbering/ -run 'TestRender|TestValidate|TestPeriodKey' -count=1
Expected: FAIL — undefined: When, undefined: Scope, undefined: Render.
- Step 3: Implement
pattern.go
package numbering
import ( "fmt" "regexp" "strconv" "strings" "time")
// Reset is when a document type's counter starts again at 1 (spec N4).type Reset string
const ( Yearly Reset = "yearly" Monthly Reset = "monthly" FiscalYearly Reset = "fiscal_yearly" Never Reset = "never")
func ParseReset(s string) (Reset, bool) { switch r := Reset(s); r { case Yearly, Monthly, FiscalYearly, Never: return r, true } return "", false}
// When is the moment a number is issued for. FiscalEndYear is set only for// journal scopes: a business document must be creatable before any fiscal// year exists (spec N5, architecture §20.1.3).type When struct { Date time.Time FiscalEndYear int}
// PatternError is a pattern or reset refused by Validate. Code is stable// vocabulary the web app translates; Detail is for logs and the API.type PatternError struct{ Code, Detail string }
func (e *PatternError) Error() string { return "numbering: " + e.Detail }
func refuse(code, detail string) error { return &PatternError{Code: code, Detail: detail} }
const maxRendered = 40 // spec N2, measured at the padding width
var ( token = regexp.MustCompile(`\{[^{}]*\}`) seqTok = regexp.MustCompile(`^\{SEQ:(\d+)\}$`) literal = regexp.MustCompile(`^[A-Za-z0-9\-/._]*$`))
type parsed struct { year, month, fy bool seqs, seqWidth int sampleLen int}
func parse(pattern string) (parsed, error) { var p parsed if pattern == "" { return p, refuse("pattern_empty", "a pattern cannot be empty") } rest := token.ReplaceAllStringFunc(pattern, func(tok string) string { switch tok { case "{YYYY}": p.year, p.sampleLen = true, p.sampleLen+4 case "{YY}": p.year, p.sampleLen = true, p.sampleLen+2 case "{MM}": p.month, p.sampleLen = true, p.sampleLen+2 case "{FY}": p.fy, p.sampleLen = true, p.sampleLen+4 default: m := seqTok.FindStringSubmatch(tok) if m == nil { return "\x00" // marks an unknown placeholder } w, _ := strconv.Atoi(m[1]) p.seqs, p.seqWidth, p.sampleLen = p.seqs+1, w, p.sampleLen+w } return "" }) if strings.ContainsAny(rest, "\x00{}") { return p, refuse("pattern_unknown_placeholder", "use only {YYYY}, {YY}, {MM}, {FY} and {SEQ:n}, each in matching braces") } if !literal.MatchString(rest) { return p, refuse("pattern_bad_character", "literal text may contain only letters, digits and - / . _") } p.sampleLen += len(rest) return p, nil}
// Validate is spec N2–N6: the one checker the API, the preview and the// tests share.func Validate(s Scope, pattern string, reset Reset) error { if _, ok := ParseReset(string(reset)); !ok { return refuse("reset_unknown", fmt.Sprintf("unknown reset rule %q", reset)) } if s.Journal && reset != FiscalYearly { return refuse("reset_journal_fixed", "a journal's numbering always restarts each fiscal year") } if !s.Journal && reset == FiscalYearly { return refuse("reset_fiscal_journal_only", "only journals can restart each fiscal year") } p, err := parse(pattern) if err != nil { return err } if p.seqs != 1 { return refuse("pattern_seq_count", "a pattern needs exactly one {SEQ:n}") } if p.seqWidth < 3 || p.seqWidth > 10 { return refuse("pattern_seq_width", "{SEQ:n} takes n from 3 to 10") } if p.sampleLen > maxRendered { return refuse("pattern_too_long", fmt.Sprintf("a number can be at most %d characters", maxRendered)) } if p.fy && !s.Journal { return refuse("pattern_fy_journal_only", "{FY} is available only to journals") } switch reset { case Yearly: if !p.year { return refuse("pattern_needs_year", "a yearly reset needs {YYYY} or {YY}") } case Monthly: if !p.year { return refuse("pattern_needs_year", "a monthly reset needs {YYYY} or {YY}, and {MM}") } if !p.month { return refuse("pattern_needs_month", "a monthly reset needs {MM}") } case FiscalYearly: if !p.fy { return refuse("pattern_needs_fy", "a journal's pattern needs {FY}") } } return nil}
// Render fills a pattern. It assumes Validate passed; it errors only on a// placeholder it cannot fill, which is a caller bug.func Render(pattern string, w When, n int64) (string, error) { var bad error out := token.ReplaceAllStringFunc(pattern, func(tok string) string { switch tok { case "{YYYY}": return fmt.Sprintf("%04d", w.Date.Year()) case "{YY}": return fmt.Sprintf("%02d", w.Date.Year()%100) case "{MM}": return fmt.Sprintf("%02d", int(w.Date.Month())) case "{FY}": if w.FiscalEndYear == 0 { bad = fmt.Errorf("numbering: {FY} without a fiscal year") } return strconv.Itoa(w.FiscalEndYear) } if m := seqTok.FindStringSubmatch(tok); m != nil { return fmt.Sprintf("%0"+m[1]+"d", n) } bad = fmt.Errorf("numbering: unknown placeholder %s", tok) return tok }) return out, bad}
// periodKey is the number_sequence.period a reset rule counts in (N4).func periodKey(r Reset, w When) (int, error) { switch r { case Yearly: return w.Date.Year(), nil case Monthly: return w.Date.Year()*100 + int(w.Date.Month()), nil case FiscalYearly: if w.FiscalEndYear == 0 { return 0, fmt.Errorf("numbering: fiscal-yearly counter without a fiscal year") } return w.FiscalEndYear, nil case Never: return 0, nil } return 0, fmt.Errorf("numbering: unknown reset %q", r)}- Step 4: Run the pattern tests
Run: go test ./internal/platform/numbering/ -run 'TestRender|TestValidate|TestPeriodKey' -count=1
Expected: FAIL only on undefined: Scope until Step 6; after Step 6, PASS.
- Step 5: Write the failing scope tests
internal/platform/numbering/scope_test.go:
package numbering
import ( "testing"
"github.com/stretchr/testify/require")
func TestRegisterBuildsTodaysDefault(t *testing.T) { s := Register("t_register", "TR", "t_table", "reference") t.Cleanup(func() { unregisterForTest("t_register") }) require.Equal(t, "TR-{YYYY}-{SEQ:4}", s.DefaultPattern) require.Equal(t, Yearly, s.DefaultReset) require.NoError(t, Validate(s, s.DefaultPattern, s.DefaultReset), "the default must pass its own rules")
got, ok := Lookup("t_register") require.True(t, ok) require.Equal(t, s, got) require.Contains(t, Registered(), s)}
func TestRegisterRefusesADuplicateKey(t *testing.T) { Register("t_dup", "TD", "t_table", "reference") t.Cleanup(func() { unregisterForTest("t_dup") }) require.Panics(t, func() { Register("t_dup", "TX", "t_table", "reference") })}
func TestRegisterRefusesAnIdentifierThatIsNotOne(t *testing.T) { require.NotPanics(t, func() { Register("t_ok_ident", "TO", "t_table", "reference") }, "PRESENCE") t.Cleanup(func() { unregisterForTest("t_ok_ident") }) require.Panics(t, func() { Register("t_bad_ident", "TB", "t_table; DROP", "reference") })}
func TestJournalScope(t *testing.T) { s := JournalScope("GJ") require.Equal(t, "journal:GJ", s.Key) require.Equal(t, "GJ-{FY}-{SEQ:4}", s.DefaultPattern) require.Equal(t, FiscalYearly, s.DefaultReset) require.True(t, s.Journal) require.NoError(t, Validate(s, s.DefaultPattern, s.DefaultReset)) _, ok := Lookup("journal:GJ") require.False(t, ok, "journal scopes are per organization, never registered")}- Step 6: Implement
scope.goandexport_test.go
internal/platform/numbering/scope.go:
package numbering
import ( "fmt" "regexp" "sort" "sync")
// Scope is one numbered document type (spec N1). Business documents are// registered once, by the module that owns the document, as a package-level// var; journals are one scope per journal, built by JournalScope.//// table and column name where the document keeps its number, for the// in-use check (plan ruling R4). They are compile-time constants, never// request input, and Register refuses anything that is not a plain// identifier.type Scope struct { Key string DefaultPattern string DefaultReset Reset Journal bool table, column string}
var ( regMu sync.RWMutex registry = map[string]Scope{} ident = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`))
// Register declares a business document type. The default pattern is// today's format, PREFIX-{YYYY}-{SEQ:4} reset yearly (N10).func Register(key, prefix, table, column string) Scope { if !ident.MatchString(table) || !ident.MatchString(column) { panic(fmt.Sprintf("numbering: Register(%q): table and column must be identifiers", key)) } s := Scope{Key: key, DefaultPattern: prefix + "-{YYYY}-{SEQ:4}", DefaultReset: Yearly, table: table, column: column} if err := Validate(s, s.DefaultPattern, s.DefaultReset); err != nil { panic(fmt.Sprintf("numbering: Register(%q): default pattern: %v", key, err)) } regMu.Lock() defer regMu.Unlock() if _, dup := registry[key]; dup { panic(fmt.Sprintf("numbering: scope %q registered twice", key)) } registry[key] = s return s}
// JournalScope is one journal's scope, keyed by its immutable sequence key// exactly as the counter always was ("journal:GJ"). Every journal writes// journal_entry.entry_no.func JournalScope(sequenceKey string) Scope { return Scope{Key: "journal:" + sequenceKey, DefaultPattern: sequenceKey + "-{FY}-{SEQ:4}", DefaultReset: FiscalYearly, Journal: true, table: "journal_entry", column: "entry_no"}}
func Lookup(key string) (Scope, bool) { regMu.RLock() defer regMu.RUnlock() s, ok := registry[key] return s, ok}
func Registered() []Scope { regMu.RLock() defer regMu.RUnlock() out := make([]Scope, 0, len(registry)) for _, s := range registry { out = append(out, s) } sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) return out}internal/platform/numbering/export_test.go:
package numbering
func unregisterForTest(key string) { regMu.Lock() defer regMu.Unlock() delete(registry, key)}- Step 7: Run all Task 1 tests, then watch one fail
Run: go test ./internal/platform/numbering/ -run 'TestRender|TestValidate|TestPeriodKey|TestRegister|TestJournalScope' -count=1
Expected: PASS.
Watch it fail once: in Validate, delete the case Monthly month check; rerun; expect TestValidateRefusalsEachWithAPassingTwin/monthly_needs_a_month to FAIL with expected "pattern_needs_month", actual "". Restore.
- Step 8: Commit
git add internal/platform/numbering/pattern.go internal/platform/numbering/pattern_test.go \ internal/platform/numbering/scope.go internal/platform/numbering/scope_test.go \ internal/platform/numbering/export_test.gogit commit -m "numbering: pattern language, validation and scope registry (N2-N6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 2: numbering_format table, settings store and Issue
Section titled “Task 2: numbering_format table, settings store and Issue”Files:
- Create:
db/migrations/01100_numbering_format.sql - Modify:
internal/kernel/db/migrationranges_test.go(+{1100, 1119, "Plan 12 document numbering"}after the Plan 10 row) - Modify:
internal/kernel/db/retention_test.go(+numbering_formatinamendable) - Create:
internal/platform/numbering/format.go,internal/platform/numbering/format_test.go - Modify:
internal/platform/numbering/numbering.go(+Issue;Allocate/Formatstay until Task 3) - Modify:
internal/platform/numbering/numbering_test.go(move the four allocation tests ontoIssue)
Interfaces:
-
Consumes: Task 1’s
Scope,Reset,When,Validate,Render,periodKey. -
Produces:
type Pending struct { Pattern string; Reset Reset; From time.Time }type Setting struct { Pattern string; Reset Reset; IsDefault bool; Pending *Pending }func Effective(ctx context.Context, tx pgx.Tx, s Scope, on time.Time) (Setting, error)// Set validates, then applies now (same rule) or schedules for 1 Jan next year (rule changes).// scheduled reports which. Refusals: *PatternError, ErrSharedJournalPattern, ErrNumberInUse.func Set(ctx context.Context, tx pgx.Tx, c clock.Clock, s Scope, w When, actor uuid.UUID, pattern string, reset Reset) (st Setting, scheduled bool, err error)func SetStart(ctx context.Context, tx pgx.Tx, c clock.Clock, s Scope, w When, actor uuid.UUID, next int64) error // ErrPeriodStarted, ErrBadStartfunc Peek(ctx context.Context, tx pgx.Tx, s Scope, w When, st Setting) (next int64, started bool, err error)func InUse(ctx context.Context, tx pgx.Tx, s Scope, number string) (bool, error)func Issue(ctx context.Context, tx pgx.Tx, s Scope, w When) (string, error)var ErrPeriodStarted, ErrBadStart, ErrNumberInUse, ErrSharedJournalPattern error -
Step 1: Migration
db/migrations/01100_numbering_format.sql:
-- +goose Up
-- Plan 12 (spec N1-N10): one organization's number layout per document-- type. No row means the scope's built-in default, today's format. A row is-- never deleted: reverting to the default writes the default (ruling R8),-- so a scheduled revert has somewhere to live.---- pending_* is a reset-rule change scheduled for 1 January (N8): the whole-- change, pattern and rule together, waits; the current pair keeps applying.CREATE TABLE numbering_format ( org_id UUID NOT NULL, scope TEXT NOT NULL, pattern TEXT NOT NULL, reset TEXT NOT NULL, pending_pattern TEXT, pending_reset TEXT, pending_from DATE, updated_by_member_id UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT numbering_format_pkey PRIMARY KEY (org_id, scope), CONSTRAINT numbering_format_org_fk FOREIGN KEY (org_id) REFERENCES organization (id), CONSTRAINT numbering_format_updated_by_fk FOREIGN KEY (org_id, updated_by_member_id) REFERENCES organization_member (org_id, id), CONSTRAINT numbering_format_reset_vocab CHECK (reset IN ('yearly','monthly','fiscal_yearly','never')), CONSTRAINT numbering_format_pending_reset_vocab CHECK (pending_reset IS NULL OR pending_reset IN ('yearly','monthly','fiscal_yearly','never')), CONSTRAINT numbering_format_pending_whole CHECK ((pending_pattern IS NULL) = (pending_reset IS NULL) AND (pending_reset IS NULL) = (pending_from IS NULL)), CONSTRAINT numbering_format_pending_on_new_year CHECK (pending_from IS NULL OR (EXTRACT(MONTH FROM pending_from) = 1 AND EXTRACT(DAY FROM pending_from) = 1)));
ALTER TABLE numbering_format ENABLE ROW LEVEL SECURITY;ALTER TABLE numbering_format FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON numbering_format USING (org_id = current_setting('app.current_org', true)::uuid);
GRANT SELECT, INSERT, UPDATE ON numbering_format TO erp_app;-- 00001's default privileges hand every new table DELETE; only REVOKE takes-- it away (00015, 00022). A deleted row would silently revert a customer's-- numbering.REVOKE DELETE ON numbering_format FROM erp_app;
-- +goose DownDROP TABLE numbering_format;If organization_member (org_id, id) has no unique constraint named for the composite FK to target, use whatever UNIQUE (org_id, id) it has (organization_member_org_id_id_key exists per \d organization_member); the FK only needs the unique index. If constraintnames_test.go objects to numbering_format_pkey, drop the explicit name and let the primary key take Postgres’s default — check how 00190-era or 00150 tables name theirs and copy that.
- Step 2: Register the range and the retention class
In internal/kernel/db/migrationranges_test.go, after {170, 189, "Plan 10"}, add:
{1100, 1119, "Plan 12 document numbering"},In internal/kernel/db/retention_test.go, in the amendable slice, add:
// Document numbering layouts (Plan 12). Changed by UPDATE; reverting // to the default writes the default rather than deleting the row, so // a scheduled revert has somewhere to live and the audit trail's // entity keeps existing. "numbering_format",Run: go test ./internal/kernel/db/ -count=1
Expected: PASS (the RLS, constraint-name, range and retention guards all see the new table). If a guard fails, read its message and fix the migration, not the guard.
- Step 3: Write the failing store tests
internal/platform/numbering/format_test.go (external test package, reusing setup from numbering_test.go, which you extend to also return the org id and seed one member as the actor):
package numbering_test
import ( "context" "errors" "testing" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/numbering")
var ( sept25 = time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC) jan2 = time.Date(2027, 1, 2, 10, 0, 0, 0, time.UTC) claims = numbering.Register("t_expense_claim", "EC", "t_doc", "reference") gj = numbering.JournalScope("GJ"))
func at(t time.Time) numbering.When { return numbering.When{Date: t} }
func TestNoConfigurationIssuesTodaysNumbers(t *testing.T) { w := newWorld(t) require.Equal(t, []string{"EC-2026-0001", "EC-2026-0002"}, w.issueN(t, claims, at(sept25), 2)) got := w.issueN(t, gj, numbering.When{Date: sept25, FiscalEndYear: 2027}, 1) require.Equal(t, []string{"GJ-2027-0001"}, got)}
func TestAPatternChangeAppliesNowAndTheCounterContinues(t *testing.T) { w := newWorld(t) w.issueN(t, claims, at(sept25), 2) scheduled := w.set(t, sept25, claims, "CLM/{YY}{MM}/{SEQ:4}", numbering.Yearly) require.False(t, scheduled, "same rule: applies at once (N7)") require.Equal(t, []string{"CLM/2609/0003"}, w.issueN(t, claims, at(sept25), 1))}
func TestMonthlyCountersAreIndependent(t *testing.T) { w := newWorld(t) w.forceRule(t, claims, "C{YY}{MM}-{SEQ:3}", numbering.Monthly) aug := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) require.Equal(t, []string{"C2608-001", "C2608-002"}, w.issueN(t, claims, at(aug), 2)) require.Equal(t, []string{"C2609-001"}, w.issueN(t, claims, at(sept25), 1))}
func TestAResetChangeWaitsForTheNewYear(t *testing.T) { w := newWorld(t) w.issueN(t, claims, at(sept25), 1) scheduled := w.set(t, sept25, claims, "C{YY}{MM}-{SEQ:3}", numbering.Monthly) require.True(t, scheduled)
st := w.effective(t, claims, sept25) require.Equal(t, "EC-{YYYY}-{SEQ:4}", st.Pattern, "the current pair keeps applying") require.NotNil(t, st.Pending) require.Equal(t, time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), st.Pending.From) require.Equal(t, []string{"EC-2026-0002"}, w.issueN(t, claims, at(sept25), 1))
require.Equal(t, []string{"C2701-001"}, w.issueN(t, claims, at(jan2), 1), "applied from 1 January")}
func TestSavingTheCurrentSettingCancelsAPendingChange(t *testing.T) { w := newWorld(t) require.True(t, w.set(t, sept25, claims, "C{YY}{MM}-{SEQ:3}", numbering.Monthly)) require.NotNil(t, w.effective(t, claims, sept25).Pending, "PRESENCE: a change is pending") require.False(t, w.set(t, sept25, claims, claims.DefaultPattern, claims.DefaultReset)) require.Nil(t, w.effective(t, claims, sept25).Pending)}
func TestStartingNumberOnlyBeforeTheFirstIssue(t *testing.T) { w := newWorld(t) require.NoError(t, w.start(t, claims, at(sept25), 5021)) require.Equal(t, []string{"EC-2026-5021"}, w.issueN(t, claims, at(sept25), 1))
err := w.start(t, claims, at(sept25), 9000) require.True(t, errors.Is(err, numbering.ErrPeriodStarted), "got %v", err)
require.True(t, errors.Is(w.start(t, claims, at(jan2), 0), numbering.ErrBadStart))}
func TestSetRefusesAPatternWhoseNextNumberIsTaken(t *testing.T) { w := newWorld(t) w.issueN(t, claims, at(sept25), 1) // EC-2026-0001; the counter now stands at 2 w.recordDoc(t, "EC-2026-0002") // as if a number in the old layout already existed
// PRESENCE: a pattern whose next number (EC-2026-00002) is free is accepted. w.set(t, sept25, claims, "EC-{YYYY}-{SEQ:5}", numbering.Yearly)
// ABSENCE: going back to {SEQ:4} would issue EC-2026-0002, which exists. _, _, err := w.trySet(t, sept25, claims, "EC-{YYYY}-{SEQ:4}", numbering.Yearly) require.True(t, errors.Is(err, numbering.ErrNumberInUse), "got %v", err)}
func TestTwoJournalsCannotShareAPattern(t *testing.T) { w := newWorld(t) fy := numbering.When{Date: sept25, FiscalEndYear: 2027} w.setAt(t, fy, gj, "JV/{FY}/{SEQ:4}", numbering.FiscalYearly) _, _, err := w.trySetAt(t, fy, numbering.JournalScope("CB"), "JV/{FY}/{SEQ:4}", numbering.FiscalYearly) require.True(t, errors.Is(err, numbering.ErrSharedJournalPattern), "got %v", err)}
func TestAnotherOrgCannotSeeOrChangeTheFormat(t *testing.T) { a, b := newWorld(t), newWorldSameDB(t) a.set(t, sept25, claims, "A-{YYYY}-{SEQ:4}", numbering.Yearly) require.Equal(t, "A-{YYYY}-{SEQ:4}", a.effective(t, claims, sept25).Pattern, "PRESENCE: org A reads its own") require.True(t, b.effective(t, claims, sept25).IsDefault, "org B sees only the default")}
func TestRollbackReturnsTheNumber(t *testing.T) { w := newWorld(t) _ = w.db.InTx(w.ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := numbering.Issue(ctx, tx, claims, at(sept25)) require.NoError(t, err) return pgx.ErrTxClosed }) require.Equal(t, []string{"EC-2026-0001"}, w.issueN(t, claims, at(sept25), 1))}(Drop the uuid and clock imports from this file if the helpers in numbering_test.go are the only users.)
The helpers (newWorld, newWorldSameDB, issueN, set, trySet, setAt, trySetAt, forceRule, effective, start, recordDoc) live in numbering_test.go next to setup. Write them now:
newWorld(t)—testdb.Start, creates a scratch tableCREATE TABLE t_doc (org_id uuid NOT NULL, reference text NOT NULL, UNIQUE (org_id, reference))viah.AdminwithGRANT SELECT, INSERT ON t_doc TO erp_app(RLS is not needed on a test-only table: every query carriesorg_id), seeds an org and one member (copyseedOrg/seedMemberfrominternal/modules/orgstructure/handlers_test.go), opensdb.Open(ctx, h.AppDSN, eventbus.New()), and returns{db, ctx: tenancy.WithOrg(…), org, actor, admin}. The scratch table name is created in the test database only, so the RLS coverage guard (which runs on its own fresh database) never sees it.newWorldSameDB(t)— a second org and member on the same handles (storehin the first world; add a package-levelsync.Onceor pass the first world in — simplest:func (w *world) other(t) *world). Rename the call in the RLS test accordingly.issueN(t, s, when, n)— n transactions each callingnumbering.IssueandINSERT INTO t_doc (org_id, reference)for business scopes; returns the numbers.set/trySet/setAt/trySetAt— one transaction callingnumbering.Set(ctx, tx, clock.Fixed(date), s, when, w.actor, pattern, reset);setrequires no error and returnsscheduled.forceRule(t, s, pattern, reset)—UPDATE/INSERTviah.Adminstraight intonumbering_format(sets up a rule without waiting for 1 January).effective(t, s, on)—numbering.Effectivein a transaction.start(t, s, when, next)—numbering.SetStartin a transaction; returns its error.recordDoc(t, ref)—INSERT INTO t_docviah.Admin.
Rewrite the four existing tests in numbering_test.go (StartsAtOneAndIncrements, PeriodsAreIndependent, AbortedTransactionReturnsTheNumber, ConcurrentAllocationsDoNotCollide) to call numbering.Issue(ctx, tx, claims, at(...)) and compare rendered strings (EC-2026-0001…); delete TestFormat (Task 1’s render tests replace it). Keep TestAllocateFailsWithoutOrgInContext, renamed TestIssueFailsWithoutOrgInContext, calling Issue with a nil tx.
- Step 4: Run to verify it fails
Run: go test ./internal/platform/numbering/ -count=1
Expected: FAIL — undefined: numbering.Issue, undefined: numbering.Set, ….
- Step 5: Implement
format.go
package numbering
import ( "context" "errors" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/tenancy")
var ( ErrPeriodStarted = errors.New("numbering: a number has already been issued in this period, so the starting number can no longer be set") ErrBadStart = errors.New("numbering: the starting number must be at least 1") ErrNumberInUse = errors.New("numbering: the next number this pattern would issue is already in use") ErrSharedJournalPattern = errors.New("numbering: another journal already uses this pattern"))
type Pending struct { Pattern string Reset Reset From time.Time}
type Setting struct { Pattern string Reset Reset IsDefault bool Pending *Pending}
func orgOf(ctx context.Context) (uuid.UUID, error) { id, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("numbering: %w", tenancy.ErrNoOrg) } return id, nil}
// Effective is the setting that applies on a date. A pending change whose// date has come is treated as applied (spec §3: applied lazily, no job).func Effective(ctx context.Context, tx pgx.Tx, s Scope, on time.Time) (Setting, error) { org, err := orgOf(ctx) if err != nil { return Setting{}, err } var ( pattern, reset string pPattern, pReset *string pFrom *time.Time ) err = tx.QueryRow(ctx, ` SELECT pattern, reset, pending_pattern, pending_reset, pending_from FROM numbering_format WHERE org_id = $1 AND scope = $2`, org, s.Key). Scan(&pattern, &reset, &pPattern, &pReset, &pFrom) if errors.Is(err, pgx.ErrNoRows) { return Setting{Pattern: s.DefaultPattern, Reset: s.DefaultReset, IsDefault: true}, nil } if err != nil { return Setting{}, fmt.Errorf("numbering: read format %s: %w", s.Key, err) } st := Setting{Pattern: pattern, Reset: Reset(reset)} if pFrom != nil { if !on.Before(*pFrom) { st.Pattern, st.Reset = *pPattern, Reset(*pReset) } else { st.Pending = &Pending{Pattern: *pPattern, Reset: Reset(*pReset), From: *pFrom} } } st.IsDefault = st.Pattern == s.DefaultPattern && st.Reset == s.DefaultReset && st.Pending == nil return st, nil}
// Set is spec N7-N8. A change that keeps the rule applies now and clears// any pending change; one that changes the rule is scheduled whole for// 1 January next year.func Set(ctx context.Context, tx pgx.Tx, c clock.Clock, s Scope, w When, actor uuid.UUID, pattern string, reset Reset) (Setting, bool, error) { org, err := orgOf(ctx) if err != nil { return Setting{}, false, err } if err := Validate(s, pattern, reset); err != nil { return Setting{}, false, err } today := clock.TodayUTC(c) cur, err := Effective(ctx, tx, s, today) if err != nil { return Setting{}, false, err } if s.Journal { var shared bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM numbering_format WHERE org_id = $1 AND scope LIKE 'journal:%' AND scope <> $2 AND pattern = $3)`, org, s.Key, pattern).Scan(&shared); err != nil { return Setting{}, false, fmt.Errorf("numbering: shared pattern: %w", err) } if shared { return Setting{}, false, ErrSharedJournalPattern } }
scheduled := reset != cur.Reset if !scheduled { next, _, err := Peek(ctx, tx, s, w, Setting{Pattern: pattern, Reset: reset}) if err != nil { return Setting{}, false, err } number, err := Render(pattern, w, next) if err != nil { return Setting{}, false, err } if used, err := InUse(ctx, tx, s, number); err != nil { return Setting{}, false, err } else if used { return Setting{}, false, ErrNumberInUse } }
// The row always carries the applied pair; pending_* the scheduled one. applied, appliedReset := pattern, reset var pPattern, pReset *string var pFrom *time.Time if scheduled { applied, appliedReset = cur.Pattern, cur.Reset pr, from := string(reset), time.Date(today.Year()+1, 1, 1, 0, 0, 0, 0, time.UTC) pPattern, pReset, pFrom = &pattern, &pr, &from } if _, err := tx.Exec(ctx, ` INSERT INTO numbering_format (org_id, scope, pattern, reset, pending_pattern, pending_reset, pending_from, updated_by_member_id, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (org_id, scope) DO UPDATE SET pattern = EXCLUDED.pattern, reset = EXCLUDED.reset, pending_pattern = EXCLUDED.pending_pattern, pending_reset = EXCLUDED.pending_reset, pending_from = EXCLUDED.pending_from, updated_by_member_id = EXCLUDED.updated_by_member_id, updated_at = EXCLUDED.updated_at`, org, s.Key, applied, string(appliedReset), pPattern, pReset, pFrom, actor, c.Now().UTC()); err != nil { return Setting{}, false, fmt.Errorf("numbering: save format %s: %w", s.Key, err) } after, err := Effective(ctx, tx, s, today) if err != nil { return Setting{}, false, err } if err := audit.Write(ctx, tx, c, audit.Entry{ EntityKind: "numbering_format", EntityID: scopeEntityID(org, s.Key), Action: "set", ActorID: &actor, Before: map[string]any{"scope": s.Key, "pattern": cur.Pattern, "reset": cur.Reset, "pending": cur.Pending}, After: map[string]any{"scope": s.Key, "pattern": after.Pattern, "reset": after.Reset, "pending": after.Pending}, }); err != nil { return Setting{}, false, err } return after, scheduled, nil}
// scopeEntityID gives the audit trail a stable id per (org, scope): the// table has a natural key, not a uuid.func scopeEntityID(org uuid.UUID, key string) uuid.UUID { return uuid.NewSHA1(org, []byte("numbering_format:"+key))}
// SetStart is spec N9: allowed only while no number has been issued in the// period, i.e. while the number_sequence row does not exist.func SetStart(ctx context.Context, tx pgx.Tx, c clock.Clock, s Scope, w When, actor uuid.UUID, next int64) error { org, err := orgOf(ctx) if err != nil { return err } if next < 1 { return ErrBadStart } st, err := Effective(ctx, tx, s, w.Date) if err != nil { return err } period, err := periodKey(st.Reset, w) if err != nil { return err } tag, err := tx.Exec(ctx, ` INSERT INTO number_sequence (org_id, scope, period, next_number) VALUES ($1,$2,$3,$4) ON CONFLICT (org_id, scope, period) DO NOTHING`, org, s.Key, period, next) if err != nil { return fmt.Errorf("numbering: set start %s: %w", s.Key, err) } if tag.RowsAffected() == 0 { return ErrPeriodStarted } return audit.Write(ctx, tx, c, audit.Entry{ EntityKind: "numbering_format", EntityID: scopeEntityID(org, s.Key), Action: "start_set", ActorID: &actor, After: map[string]any{"scope": s.Key, "period": period, "next_number": next}, })}
// Peek is the next number the setting would issue in w's period, without// taking it. started reports whether the period has issued anything.func Peek(ctx context.Context, tx pgx.Tx, s Scope, w When, st Setting) (int64, bool, error) { org, err := orgOf(ctx) if err != nil { return 0, false, err } period, err := periodKey(st.Reset, w) if err != nil { return 0, false, err } var next int64 err = tx.QueryRow(ctx, `SELECT next_number FROM number_sequence WHERE org_id = $1 AND scope = $2 AND period = $3`, org, s.Key, period).Scan(&next) if errors.Is(err, pgx.ErrNoRows) { return 1, false, nil } if err != nil { return 0, false, fmt.Errorf("numbering: peek %s: %w", s.Key, err) } return next, true, nil}
// InUse reports whether a document of this scope already carries number// (ruling R4). table and column are Register's validated identifiers.func InUse(ctx context.Context, tx pgx.Tx, s Scope, number string) (bool, error) { org, err := orgOf(ctx) if err != nil { return false, err } var used bool q := fmt.Sprintf(`SELECT EXISTS (SELECT 1 FROM %s WHERE org_id = $1 AND %s = $2)`, s.table, s.column) if err := tx.QueryRow(ctx, q, org, number).Scan(&used); err != nil { return false, fmt.Errorf("numbering: in use %s: %w", s.Key, err) } return used, nil}Note on the tenant-query guard: TestEveryTenantQueryCarriesAnExplicitOrgPredicate scans SQL string literals. The fmt.Sprintf query carries org_id = $1; if the guard cannot parse the formatted literal, add the file/line to its documented exception list with a one-line reason (the identifiers are compile-time constants), rather than weakening the guard.
- Step 6: Add
Issuetonumbering.go
Append:
// Issue is the one way a document number is made (spec N14): read the// effective format, take the next number on the counter its reset rule// names, render. MUST be called inside the transaction that creates the// document; a rollback gives the number back.func Issue(ctx context.Context, tx pgx.Tx, s Scope, w When) (string, error) { st, err := Effective(ctx, tx, s, w.Date) if err != nil { return "", err } period, err := periodKey(st.Reset, w) if err != nil { return "", err } n, err := Allocate(ctx, tx, s.Key, period) if err != nil { return "", err } return Render(st.Pattern, w, n)}- Step 7: Run the package tests
Run: go test ./internal/platform/numbering/... ./internal/kernel/db/... -count=1
Expected: PASS.
Watch two guards fail once:
- In
Set, changescheduled := reset != cur.Resettoscheduled := false; expectTestAResetChangeWaitsForTheNewYearto FAIL. Restore. - In
SetStart, changeON CONFLICT … DO NOTHINGtoDO UPDATE SET next_number = EXCLUDED.next_number; expectTestStartingNumberOnlyBeforeTheFirstIssueto FAIL. Restore.
- Step 8: Commit
git add db/migrations/01100_numbering_format.sql internal/kernel/db/migrationranges_test.go \ internal/kernel/db/retention_test.go internal/platform/numbering/git commit -m "numbering: per-organization formats, scheduled reset changes, starting number, Issue (N7-N12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 3: Every call site issues through Issue; the bypass guard
Section titled “Task 3: Every call site issues through Issue; the bypass guard”Files:
- Modify:
internal/platform/expenses/claim.go:48-49,168-194,internal/platform/expenses/group.go:37-38,116-128 - Modify:
internal/platform/advances/advance.go:57-58,314-342 - Modify:
internal/platform/pettycash/float.go:50-51,241-246,spend.go:33-34,378-383,topup.go:42-43,164-169,replenish.go:156-161,closeout.go:41-42,161-166 - Modify:
internal/platform/accounting/ledger/post.go:138-148,reverse.go:147,160-185 - Modify:
internal/platform/numbering/numbering.go(Allocate→ unexportedallocate, deleteFormat) - Create:
internal/platform/numbering/bypass_test.go - Create:
internal/app/numbering_scopes_test.go
Interfaces:
-
Consumes:
numbering.Register,numbering.JournalScope,numbering.Issue,numbering.When(Tasks 1–2). -
Produces: seven registered scopes —
expense_claim(EC,expense_claim.reference),expense_claim_group(EG,expense_claim_group.reference),advance(AD,advance.reference),petty_cash_float(PF,petty_cash_float.code),petty_cash_spend(PS,petty_cash_spend.reference),float_topup(PT,float_topup.reference),float_closeout(PX,float_closeout.reference). Each owning package exports nothing new; the scope var is unexported. -
Step 1: Write the failing registry test
internal/app/numbering_scopes_test.go:
package app_test
import ( "testing"
"github.com/stretchr/testify/require"
_ "github.com/mcom/erp/internal/app" // composes every module, so every scope registers "github.com/mcom/erp/internal/platform/numbering")
// Spec drift test 3: every numbered business document is registered, so the// Document numbering screen lists it. A document type added later must// register here too; this list changes with it, deliberately.func TestEveryNumberedDocumentTypeIsRegistered(t *testing.T) { var keys []string for _, s := range numbering.Registered() { keys = append(keys, s.Key) } require.Equal(t, []string{ "advance", "expense_claim", "expense_claim_group", "float_closeout", "float_topup", "petty_cash_float", "petty_cash_spend", }, keys)}Run: go test ./internal/app/ -run TestEveryNumberedDocumentTypeIsRegistered -count=1
Expected: FAIL — keys is empty.
- Step 2: Move each business call site
Pattern, shown for expenses/claim.go. Replace the constants block entries
numberingScopeClaim = "expense_claim" claimReferencePrefix = "EC"with a package-level var (outside the const block):
// claimScope keeps the counter key the claim always used, so existing// counters carry on (spec N10).var claimScope = numbering.Register("expense_claim", "EC", "expense_claim", "reference")and replace
year := clock.TodayUTC(c).Year() n, err := numbering.Allocate(ctx, tx, numberingScopeClaim, year) if err != nil { return Claim{}, fmt.Errorf("expenses: create claim: %w", err) }with
reference, err := numbering.Issue(ctx, tx, claimScope, numbering.When{Date: clock.TodayUTC(c)}) if err != nil { return Claim{}, fmt.Errorf("expenses: create claim: %w", err) }deleting the later reference := numbering.Format(claimReferencePrefix, year, n) line (the reference variable now comes from Issue). If year is used elsewhere in the function, keep computing it there; otherwise remove it.
Do the same at every business site:
| File | Old constants | New var | Variable that receives the number |
|---|---|---|---|
expenses/group.go |
numberingScopeGroup, groupReferencePrefix |
groupScope = numbering.Register("expense_claim_group", "EG", "expense_claim_group", "reference") |
reference, passed where numbering.Format(groupReferencePrefix, year, n) was |
advances/advance.go |
numberingScopeAdvance, referencePrefix |
advanceScope = numbering.Register("advance", "AD", "advance", "reference") |
reference (When{Date: today} — this file already has today) |
pettycash/float.go |
numberingScopeFloat, floatReferencePrefix |
floatScope = numbering.Register("petty_cash_float", "PF", "petty_cash_float", "code") |
code |
pettycash/spend.go |
numberingScopeSpend, spendReferencePrefix |
spendScope = numbering.Register("petty_cash_spend", "PS", "petty_cash_spend", "reference") |
reference |
pettycash/topup.go |
numberingScopeTopUp, topUpReferencePrefix |
topUpScope = numbering.Register(KindTopUp, "PT", "float_topup", "reference") |
reference |
pettycash/replenish.go |
uses topup’s | topUpScope (same var — one scope, two creators) |
reference |
pettycash/closeout.go |
numberingScopeCloseout, closeoutReferencePrefix |
closeoutScope = numbering.Register(KindCloseout, "PX", "float_closeout", "reference") |
reference |
Keep the // Ruling PC26 comments by moving them onto the new var lines. If a removed constant is referenced by a test in the same package, update the test to the scope’s .Key.
- Step 3: Move the ledger
ledger/post.go — replace
n, err := numbering.Allocate(ctx, tx, "journal:"+j.SequenceKey, year.EndYear) if err != nil { return Entry{}, fmt.Errorf("ledger: post: %w", err) }with
entryNo, err := numbering.Issue(ctx, tx, numbering.JournalScope(j.SequenceKey), numbering.When{Date: date, FiscalEndYear: year.EndYear}) if err != nil { return Entry{}, fmt.Errorf("ledger: post: %w", err) }and EntryNo: numbering.Format(j.SequenceKey, year.EndYear, n), with EntryNo: entryNo,.
ledger/reverse.go — the lock-order loop stays exactly as it is (sorted by scope, then year.EndYear); only its body changes. Replace the n int64 field on the plan struct with entryNo string, and in the loop:
for _, i := range byScope { p := &plans[i] entryNo, err := numbering.Issue(ctx, tx, numbering.JournalScope(p.journal.SequenceKey), numbering.When{Date: p.date, FiscalEndYear: p.year.EndYear}) if err != nil { return nil, fmt.Errorf("ledger: reverse: %w", err) } p.entryNo = entryNo }then EntryNo: numbering.Format(p.journal.SequenceKey, p.year.EndYear, p.n), becomes EntryNo: p.entryNo,. p.scope is still set on line 147 and still drives the sort; it equals JournalScope(...).Key by construction.
- Step 4: Unexport
Allocate, deleteFormat
In numbering.go, rename Allocate to allocate (and its doc comment), update Issue to call allocate, and delete Format and its doc comment. Update the package doc comment’s first paragraph to say numbers are issued with Issue.
Run: go build ./... && go vet ./internal/platform/...
Expected: success. Any remaining numbering.Allocate / numbering.Format reference is a site you missed — fix it.
- Step 5: The bypass guard
internal/platform/numbering/bypass_test.go — copy the repo walk from internal/kernel/audit/lockconvention_test.go (its repoRoot helper and the nested-worktree skip) and assert:
package numbering_test
// Spec N14: nothing outside this package allocates, formats, or builds a// Scope by hand. Issue is the only door; a Scope literal would let an// unregistered document type through it, invisible to the admin screen.
var ( issueCall = regexp.MustCompile(`\bnumbering\.Issue\(`) scopeLit = regexp.MustCompile(`\bnumbering\.Scope\{`) legacyCall = regexp.MustCompile(`\bnumbering\.(Allocate|Format)\(`))
func TestNothingBypassesIssue(t *testing.T) { root := repoRoot(t) var filesRead, issues int var bad []string require.NoError(t, filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { switch d.Name() { case ".git", "bin", "node_modules", "web": return filepath.SkipDir } if path != root { if _, err := os.Stat(filepath.Join(path, ".git")); err == nil { return filepath.SkipDir } } return nil } if !strings.HasSuffix(path, ".go") || strings.Contains(path, filepath.Join("internal", "platform", "numbering")+string(filepath.Separator)) { return nil } b, err := os.ReadFile(path) if err != nil { return err } filesRead++ for i, line := range strings.Split(string(b), "\n") { issues += len(issueCall.FindAllString(line, -1)) if scopeLit.MatchString(line) || legacyCall.MatchString(line) { rel, _ := filepath.Rel(root, path) bad = append(bad, fmt.Sprintf("%s:%d", rel, i+1)) } } return nil })) require.Greater(t, filesRead, 100, "PRESENCE: the walk read the repository") require.GreaterOrEqual(t, issues, 10, "PRESENCE: the guard sees the ten call sites; if it sees none it proves nothing") require.Empty(t, bad, "issue document numbers with numbering.Issue and a registered Scope only")}(Adjust imports: fmt, os, path/filepath, regexp, runtime, strings, testing, require.)
- Step 6: Run and watch both guards fail once
Run: go test ./internal/platform/numbering/ ./internal/app/ -run 'TestNothingBypassesIssue|TestEveryNumberedDocumentTypeIsRegistered' -count=1
Expected: PASS.
Watch them fail:
- In
expenses/group.goadd a stray linevar _ = numbering.Scope{Key: "x"}; expectTestNothingBypassesIssueto FAIL naminginternal/platform/expenses/group.go:<line>. Remove it. - Comment out the
advanceScoperegistration (replace withvar advanceScope = numbering.JournalScope("XX"), which compiles and is not registered); expectTestEveryNumberedDocumentTypeIsRegisteredto FAIL withadvancemissing. Restore.
- Step 7: Run the touched packages
Run: go test ./internal/platform/numbering/... ./internal/platform/expenses/... ./internal/platform/advances/... ./internal/platform/pettycash/... ./internal/platform/accounting/... ./internal/app/... -count=1
Expected: PASS — every existing test that asserts a reference (EC-2026-0001, GJ-2027-0001, …) still passes unchanged. That is the N10 proof; do not edit an expected reference to make a test pass.
- Step 8: Commit
git add internal/platform internal/app/numbering_scopes_test.gogit commit -m "numbering: every document issues through Issue; bypass and registry guards (N14)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 4: API — /org/numbering, the permission, the module
Section titled “Task 4: API — /org/numbering, the permission, the module”Files:
- Modify:
api/openapi.yaml(+ 5 operations, schemasNumberingFormat,NumberingFormatList,NumberingPending,NumberingFormatInput,NumberingStartInput,NumberingPreviewInput,NumberingPreview,NumberingSaved; responseNumberingForbidden) - Modify:
constants.yaml(+org.numbering.manageunderpermissions:) — thenmake generate - Create:
db/migrations/01101_org_numbering_manage.sql - Create:
internal/modules/numbering/permissions.go,routes.go,handlers.go,errors.go - Create:
internal/modules/numbering/routes_test.go,handlers_test.go - Modify:
internal/app/app.go(Deps.Numbering,numbering.Register(api, deps.Numbering)afteradvances.Register) - Modify:
internal/app/routes_test.go(composedModules["numbering"] = {numbering.PermissionManage}) - Modify:
cmd/api/main.go(Numbering: numberingmod.Deps{DB: database, Clock: clock.System()})
Interfaces:
-
Consumes:
numbering.Lookup,Registered,JournalScope,Effective,Set,SetStart,Peek,InUse,Validate,Render,When,ParseReset,PatternError, the fourErr…sentinels;chart.ListJournals(ctx, tx) ([]chart.Journal, error);fiscal.YearContaining(ctx, tx, day) (fiscal.Year, error). -
Produces (wire shapes, snake_case, all strings except counts):
NumberingFormat { scope, kind: "document"|"journal", journal_code?, journal_name?, pattern, reset, is_default, pending?: {pattern, reset, from}, next_number: int64, next_preview, can_set_start: bool }GET /org/numbering→{ formats: NumberingFormat[] }— registered scopes first (sorted by key), then journals (by code).PUT /org/numbering/{scope}body{pattern, reset}→{ format: NumberingFormat, scheduled: bool }DELETE /org/numbering/{scope}→ same shape (revert to default viaSet)POST /org/numbering/{scope}/startbody{next_number}→NumberingFormatPOST /org/numbering/previewbody{scope, pattern, reset}→{ sample?, in_use: bool, error_code?, error_detail? }— always 200 for a pattern problem (the preview’s job is to explain it); 404 for an unknown scope.- Errors: 422
*PatternErrorwithcode= its Code; 409number_in_use,pattern_shared_with_journal,period_started; 422bad_start,no_fiscal_year; 404 unknown scope; 403 without the permission.
-
Step 1: Declare the contract
Add the five operations to api/openapi.yaml next to /org/roles, following /org/roles/{id}/permissions exactly (no tags; bearerAuth security as the file’s other /org routes; operationIds listNumberingFormats, setNumberingFormat, resetNumberingFormat, setNumberingStart, previewNumberingFormat). Add NumberingForbidden under components/responses beside RolesForbidden, worded: “The caller lacks org.numbering.manage.” The scope path parameter is { type: string, pattern: '^[a-z_]+$|^journal:[A-Z0-9]{2,10}$' }. reset is an enum of the four strings. next_number is { type: integer, format: int64, minimum: 1 }.
Add to constants.yaml under permissions: (alphabetical with the other org.*):
- org.numbering.manageRun: make generate
Expected: internal/gen/api/api.gen.go, web/src/lib/api/schema.d.ts, internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts change; go test ./internal/gen/... ./internal/kernel/vocab/... -count=1 PASS.
- Step 2: Grant it to existing Owners
db/migrations/01101_org_numbering_manage.sql — copy 00170_org_roles_manage.sql verbatim, replacing every org.roles.manage with org.numbering.manage, and the header comment’s first line with -- Plan 12 (N13): org.numbering.manage gates the Document numbering screen. Keep the rest of its comment: the reasons (provisioning never reaches an existing Owner; the migration role bypasses RLS; the perm_version bump) are identical.
- Step 3: Write the failing routes test
internal/modules/numbering/routes_test.go:
package numbering_test
import ( "strings" "testing"
"github.com/gin-gonic/gin" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/modules/numbering")
func TestEveryRouteRequiresItsExactPermission(t *testing.T) { gin.SetMode(gin.TestMode) httpx.ResetAuthorizedRoutes() r, api := httpx.NewRouter(httpx.Deps{}) numbering.Register(api, numbering.Deps{})
want := map[string]string{ "GET /v1/org/numbering": numbering.PermissionManage, "PUT /v1/org/numbering/:scope": numbering.PermissionManage, "DELETE /v1/org/numbering/:scope": numbering.PermissionManage, "POST /v1/org/numbering/:scope/start": numbering.PermissionManage, "POST /v1/org/numbering/preview": numbering.PermissionManage, } require.Equal(t, want, httpx.AuthorizedRoutes()) require.Equal(t, "org.numbering.manage", numbering.PermissionManage)
var registered int for _, rt := range r.Routes() { if strings.HasPrefix(rt.Path, "/v1/org/numbering") { registered++ } } require.Equal(t, len(want), registered, "every route is pinned above")}Gin cannot register both /org/numbering/preview (static) and /org/numbering/:scope for POST if they conflict; they do not (POST /:scope does not exist, only POST /:scope/start), but if Gin reports a wildcard conflict, move preview to POST /org/numbering-preview in both the spec and this test, and say so in the commit message.
Run: go test ./internal/modules/numbering/ -count=1
Expected: FAIL — package does not exist.
- Step 4: Implement the module
permissions.go:
package numbering
import "github.com/mcom/erp/internal/platform/identity/member"
// PermissionManage gates reading and changing document number formats,// journals included (spec N13).const PermissionManage = "org.numbering.manage"
func init() { member.RegisterPermission(PermissionManage, "Set how document numbers look for each document type, including journals, and a starting number when moving from another system")}routes.go — copy internal/modules/orgstructure/routes.go’s package comment style, Deps{DB *db.DB; Clock clock.Clock}, its permissionLoader, and register:
func Register(g *gin.RouterGroup, deps Deps) { h := handlers{deps: deps} p := permissionLoader{db: deps.DB} httpx.Protected(g, p, "GET", "/org/numbering", PermissionManage, h.list) httpx.Protected(g, p, "POST", "/org/numbering/preview", PermissionManage, h.preview) httpx.Protected(g, p, "PUT", "/org/numbering/:scope", PermissionManage, h.set) httpx.Protected(g, p, "DELETE", "/org/numbering/:scope", PermissionManage, h.reset) httpx.Protected(g, p, "POST", "/org/numbering/:scope/start", PermissionManage, h.start)}handlers.go — the pieces every handler shares:
// resolve turns a path scope into a Scope: a registered business document,// or "journal:<sequence_key>" of a journal this organization has.func resolve(ctx context.Context, tx pgx.Tx, key string) (numbering.Scope, *chart.Journal, error) { if s, ok := numbering.Lookup(key); ok { return s, nil, nil } if seq, ok := strings.CutPrefix(key, "journal:"); ok { js, err := chart.ListJournals(ctx, tx) if err != nil { return numbering.Scope{}, nil, err } for i := range js { if js[i].SequenceKey == seq { return numbering.JournalScope(seq), &js[i], nil } } } return numbering.Scope{}, nil, errUnknownScope}
// when is "now" for a scope: today, plus the fiscal year containing today// for a journal. A journal with no fiscal year yet previews with today's// calendar year (ruling R7); ok=false tells SetStart to refuse.func when(ctx context.Context, tx pgx.Tx, c clock.Clock, s numbering.Scope) (numbering.When, bool, error) { today := clock.TodayUTC(c) if !s.Journal { return numbering.When{Date: today}, true, nil } y, err := fiscal.YearContaining(ctx, tx, today) if errors.Is(err, fiscal.ErrNotFound) { return numbering.When{Date: today, FiscalEndYear: today.Year()}, false, nil } if err != nil { return numbering.When{}, false, err } return numbering.When{Date: today, FiscalEndYear: y.EndYear}, true, nil}
// dto builds the wire row: the effective setting, the next number and its// rendering, and whether the starting number may still be set.func dto(ctx context.Context, tx pgx.Tx, c clock.Clock, s numbering.Scope, j *chart.Journal) (formatDTO, error)dto calls when, numbering.Effective, numbering.Peek, numbering.Render, and sets can_set_start = !started && ok.
Handlers (list, set, reset, start, preview) each: httpx.IdentityFromContext for the actor (as identity/role_handlers.go:setRolePermissions), bind JSON with c.ShouldBindJSON (422 on failure), run everything in one h.deps.DB.InTx, and _ = c.Error(numberingErr(err)) on failure. reset calls numbering.Set(…, s.DefaultPattern, s.DefaultReset). preview validates with numbering.Validate; on *PatternError returns 200 with error_code/error_detail; otherwise renders the next number (Peek under the proposed setting) and reports numbering.InUse.
errors.go:
var errUnknownScope = errors.New("numbering: no such document type in this organization")
func numberingErr(err error) error { var pe *numbering.PatternError switch { case errors.As(err, &pe): return apperr.New(apperr.KindValidation, pe.Detail).With("code", pe.Code) case errors.Is(err, errUnknownScope): return apperr.New(apperr.KindNotFound, strings.TrimPrefix(err.Error(), "numbering: ")) case errors.Is(err, numbering.ErrNumberInUse): return apperr.New(apperr.KindConflict, trim(err)).With("code", "number_in_use") case errors.Is(err, numbering.ErrSharedJournalPattern): return apperr.New(apperr.KindConflict, trim(err)).With("code", "pattern_shared_with_journal") case errors.Is(err, numbering.ErrPeriodStarted): return apperr.New(apperr.KindConflict, trim(err)).With("code", "period_started") case errors.Is(err, numbering.ErrBadStart): return apperr.New(apperr.KindValidation, trim(err)).With("code", "bad_start") case errors.Is(err, errNoFiscalYear): return apperr.New(apperr.KindValidation, trim(err)).With("code", "no_fiscal_year") } return err}
func trim(err error) string { return strings.TrimPrefix(err.Error(), "numbering: ") }(errNoFiscalYear is returned by start when when reports ok == false.)
Wire it: internal/app/app.go — add Numbering numbering.Deps to Deps and numbering.Register(api, deps.Numbering) after advances.Register; internal/app/routes_test.go — add "numbering": {numbering.PermissionManage}, to composedModules; cmd/api/main.go — add the Numbering: deps entry. Import the module with an alias (numberingmod) wherever the platform package is also imported.
- Step 5: Write the handler tests
internal/modules/numbering/handlers_test.go — copy newEnv, seedOrg, seedMember, grant, mint and do from internal/modules/orgstructure/handlers_test.go (with clock.Fixed(time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC))), and cover:
type formatResp struct { Scope string `json:"scope"` Kind string `json:"kind"` Pattern string `json:"pattern"` Reset string `json:"reset"` IsDefault bool `json:"is_default"` NextPreview string `json:"next_preview"` CanSetStart bool `json:"can_set_start"` Pending *struct { Pattern, Reset, From string } `json:"pending"`}
type listResp struct{ Formats []formatResp `json:"formats"` }type savedResp struct { Format formatResp `json:"format"` Scheduled bool `json:"scheduled"`}type problemResp struct { Status int `json:"status"` Code string `json:"code"`}
func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { t.Helper() var v T require.NoError(t, json.Unmarshal(w.Body.Bytes(), &v), w.Body.String()) return v}
func byScope(l listResp, scope string) (formatResp, bool) { for _, f := range l.Formats { if f.Scope == scope { return f, true } } return formatResp{}, false}
// seedJournal adds journal GJ. Check 00050_accounting_reference.sql for any// further NOT NULL column without a default and add it here.func seedJournal(t *testing.T, admin *pgxpool.Pool, org uuid.UUID) { t.Helper() _, err := admin.Exec(context.Background(), ` INSERT INTO journal (org_id, code, name, type, sequence_key, created_at, updated_at) VALUES ($1, 'GJ', 'General', 'misc', 'GJ', now(), now())`, org) require.NoError(t, err)}
func TestListShowsEveryDocumentTypeWithTodaysDefault(t *testing.T) { e := newEnv(t) a := e.tenant(t, "Alpha") // manager holds org.numbering.manage; reader holds nothing seedJournal(t, e.h.Admin, a.org)
w := e.do(t, "GET", "/v1/org/numbering", a.managerTok, nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) l := decode[listResp](t, w)
claim, ok := byScope(l, "expense_claim") require.True(t, ok) require.True(t, claim.IsDefault) require.Equal(t, "EC-2026-0001", claim.NextPreview) require.True(t, claim.CanSetStart)
gj, ok := byScope(l, "journal:GJ") require.True(t, ok) require.Equal(t, "journal", gj.Kind) require.Equal(t, "fiscal_yearly", gj.Reset) require.Equal(t, "GJ-{FY}-{SEQ:4}", gj.Pattern) require.False(t, gj.CanSetStart, "no fiscal year covers today in this fixture (ruling R7)")}
func TestWithoutThePermissionEveryRouteIs403(t *testing.T) { e := newEnv(t) a := e.tenant(t, "Alpha") require.Equal(t, http.StatusOK, e.do(t, "GET", "/v1/org/numbering", a.managerTok, nil).Code, "PRESENCE")
for _, rt := range []struct{ method, path string }{ {"GET", "/v1/org/numbering"}, {"PUT", "/v1/org/numbering/expense_claim"}, {"DELETE", "/v1/org/numbering/expense_claim"}, {"POST", "/v1/org/numbering/expense_claim/start"}, {"POST", "/v1/org/numbering/preview"}, } { w := e.do(t, rt.method, rt.path, a.readTok, map[string]any{}) require.Equal(t, http.StatusForbidden, w.Code, rt.method+" "+rt.path) }}
func TestPutAppliesOrSchedules(t *testing.T) { e := newEnv(t) a := e.tenant(t, "Alpha")
w := e.do(t, "PUT", "/v1/org/numbering/expense_claim", a.managerTok, map[string]any{"pattern": "CLM/{YY}{MM}/{SEQ:4}", "reset": "yearly"}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) now := decode[savedResp](t, w) require.False(t, now.Scheduled) require.Equal(t, "CLM/2609/0001", now.Format.NextPreview)
w = e.do(t, "PUT", "/v1/org/numbering/expense_claim", a.managerTok, map[string]any{"pattern": "C{YY}{MM}-{SEQ:3}", "reset": "monthly"}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) later := decode[savedResp](t, w) require.True(t, later.Scheduled) require.Equal(t, "CLM/{YY}{MM}/{SEQ:4}", later.Format.Pattern, "the applied pattern is unchanged") require.NotNil(t, later.Format.Pending) require.Equal(t, "2027-01-01", later.Format.Pending.From)}
func TestPutRefusalsCarryTheirCode(t *testing.T) { e := newEnv(t) a := e.tenant(t, "Alpha") seedJournal(t, e.h.Admin, a.org)
for _, c := range []struct { path, pattern, reset string status int code string }{ {"expense_claim", "EC-{SEQ:4}", "yearly", http.StatusUnprocessableEntity, "pattern_needs_year"}, {"journal:GJ", "GJ-{FY}-{SEQ:4}", "yearly", http.StatusUnprocessableEntity, "reset_journal_fixed"}, {"nope", "EC-{YYYY}-{SEQ:4}", "yearly", http.StatusNotFound, ""}, } { w := e.do(t, "PUT", "/v1/org/numbering/"+c.path, a.managerTok, map[string]any{"pattern": c.pattern, "reset": c.reset}) require.Equal(t, c.status, w.Code, w.Body.String()) require.Equal(t, c.code, decode[problemResp](t, w).Code) }}
func TestStartOnlyOnAFreshPeriod(t *testing.T) { e := newEnv(t) a := e.tenant(t, "Alpha") seedJournal(t, e.h.Admin, a.org)
w := e.do(t, "POST", "/v1/org/numbering/expense_claim/start", a.managerTok, map[string]any{"next_number": 5021}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) f := decode[formatResp](t, w) require.Equal(t, "EC-2026-5021", f.NextPreview) require.False(t, f.CanSetStart)
w = e.do(t, "POST", "/v1/org/numbering/expense_claim/start", a.managerTok, map[string]any{"next_number": 9000}) require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, "period_started", decode[problemResp](t, w).Code)
w = e.do(t, "POST", "/v1/org/numbering/journal:GJ/start", a.managerTok, map[string]any{"next_number": 10}) require.Equal(t, http.StatusUnprocessableEntity, w.Code) require.Equal(t, "no_fiscal_year", decode[problemResp](t, w).Code)}
func TestPreviewExplainsWithoutSaving(t *testing.T) { e := newEnv(t) a := e.tenant(t, "Alpha")
w := e.do(t, "POST", "/v1/org/numbering/preview", a.managerTok, map[string]any{"scope": "expense_claim", "pattern": "EC-{SEQ:4}", "reset": "yearly"}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) bad := decode[struct { ErrorCode string `json:"error_code"` }](t, w) require.Equal(t, "pattern_needs_year", bad.ErrorCode)
w = e.do(t, "POST", "/v1/org/numbering/preview", a.managerTok, map[string]any{"scope": "expense_claim", "pattern": "INV-{YYYY}-{SEQ:5}", "reset": "yearly"}) good := decode[struct { Sample string `json:"sample"` InUse bool `json:"in_use"` }](t, w) require.Equal(t, "INV-2026-00001", good.Sample) require.False(t, good.InUse)
l := decode[listResp](t, e.do(t, "GET", "/v1/org/numbering", a.managerTok, nil)) claim, _ := byScope(l, "expense_claim") require.True(t, claim.IsDefault, "a preview saves nothing")}
func TestResetRevertsToTheDefault(t *testing.T) { e := newEnv(t) a := e.tenant(t, "Alpha") w := e.do(t, "PUT", "/v1/org/numbering/expense_claim", a.managerTok, map[string]any{"pattern": "X-{YYYY}-{SEQ:4}", "reset": "yearly"}) require.False(t, decode[savedResp](t, w).Format.IsDefault, "PRESENCE: customized")
w = e.do(t, "DELETE", "/v1/org/numbering/expense_claim", a.managerTok, nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.True(t, decode[savedResp](t, w).Format.IsDefault)}
func TestAnotherOrganizationCannotReadTheFormat(t *testing.T) { e := newEnv(t) a, b := e.tenant(t, "Alpha"), e.tenant(t, "Beta") e.do(t, "PUT", "/v1/org/numbering/expense_claim", a.managerTok, map[string]any{"pattern": "A-{YYYY}-{SEQ:4}", "reset": "yearly"})
la := decode[listResp](t, e.do(t, "GET", "/v1/org/numbering", a.managerTok, nil)) fa, _ := byScope(la, "expense_claim") require.Equal(t, "A-{YYYY}-{SEQ:4}", fa.Pattern, "PRESENCE: org A reads its own")
lb := decode[listResp](t, e.do(t, "GET", "/v1/org/numbering", b.managerTok, nil)) fb, _ := byScope(lb, "expense_claim") require.True(t, fb.IsDefault)}e.tenant is orgstructure’s helper adapted: the manager is granted numbering.PermissionManage, the reader nothing.
- Step 6: Run and watch it fail once
Run: go test ./internal/modules/numbering/... ./internal/app/... ./internal/platform/identity/... ./internal/platform/provisioning/... -count=1
Expected: PASS, including TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestFreshOrgOwnerGetsEveryRegisteredPermission.
Watch it fail: change the DELETE route’s permission to "org.roles.manage"; expect TestEveryRouteRequiresItsExactPermission to FAIL. Restore.
- Step 7: Commit
git add api/openapi.yaml constants.yaml internal/gen internal/kernel/vocab packages/contracts \ web/src/lib/api/schema.d.ts db/migrations/01101_org_numbering_manage.sql \ internal/modules/numbering internal/app cmd/api/main.gogit commit -m "numbering: /org/numbering API behind org.numbering.manage (N13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 5: The Document numbering screen
Section titled “Task 5: The Document numbering screen”Files:
- Create:
web/src/features/numbering/api.ts,routes.tsx,NumberingPage.tsx,FormatEditor.tsx - Create:
web/src/features/numbering/NumberingPage.test.tsx,FormatEditor.test.tsx - Create:
web/src/lib/i18n/locales/{en,ms,zh}/numbering.json - Modify:
web/src/lib/api/types.ts(+NumberingFormat,NumberingPreviewaliases) - Modify:
web/src/app/shell/nav.ts(append after the/admin/workflowsentry — the file is append-only) - Modify:
web/src/app/routes.ts(one import, one spread — append-only)
Interfaces:
-
Consumes: Task 4’s generated
paths['/org/numbering']etc. inschema.d.ts;api,unwrap,problemMessage,applyProblemToForm,refusalText(web/src/lib/api/problem.ts);DataTable,ColumnDef,Drawer,PageHeader,Button,Field,Input,Select,EmptyState,Tag(web/src/components/ui/*);RequirePermission(@/features/auth/session);toast. -
Produces:
export const numberingKeys = { list: ['numbering', 'list'] as const,preview: (scope: string, pattern: string, reset: string) => ['numbering', 'preview', scope, pattern, reset] as const };export function useNumberingFormats(): UseQueryResult<NumberingFormat[]>export function useSaveNumberingFormat(): UseMutationResult<{format: NumberingFormat; scheduled: boolean}, unknown, {scope: string; pattern: string; reset: Reset}>export function useResetNumberingFormat(): UseMutationResult<…, unknown, {scope: string}>export function useSetNumberingStart(): UseMutationResult<NumberingFormat, unknown, {scope: string; next_number: number}>export function useNumberingPreview(scope: string, pattern: string, reset: Reset): UseQueryResult<NumberingPreview>export function scopeLabel(t: TFunction, f: NumberingFormat): string // journals: "{{code}} — {{name}}"; documents: t(`numbering:scopes.${f.scope}`, { defaultValue: f.scope }) -
Step 1: Translations
web/src/lib/i18n/locales/en/numbering.json:
{ "nav": "Document numbering", "page": { "title": "Document numbering", "subtitle": "How each document's number looks. Changes apply to documents created from now on; numbers already issued never change." }, "list": { "document": "Document type", "pattern": "Pattern", "reset": "Restarts", "next": "Next number", "default": "Default", "scheduled": "Changes on {{date}}" }, "scopes": { "expense_claim": "Expense claim", "expense_claim_group": "Claim group", "advance": "Advance", "petty_cash_float": "Petty cash float", "petty_cash_spend": "Petty cash spend", "float_topup": "Float top-up", "float_closeout": "Float close-out" }, "journal": "Journal {{code}} — {{name}}", "reset": { "yearly": "Every year", "monthly": "Every month", "fiscal_yearly": "Every fiscal year", "never": "Never" }, "editor": { "title": "Numbering for {{name}}", "pattern": "Pattern", "patternHint": "Letters, digits and - / . _ with the placeholders below. Exactly one sequence.", "insert": "Insert", "reset": "Restart the count", "preview": "Next number", "previewInUse": "{{number}} is already used by another document. Pick a different pattern.", "scheduledNote": "Changing when the count restarts takes effect on {{date}}. Until then, the current pattern keeps applying.", "pendingNote": "Scheduled for {{date}}: {{pattern}}, restarting {{reset}}.", "start": "Starting number", "startHint": "Only before the first number of this period is issued. Use it to continue from your previous system.", "setStart": "Set starting number", "save": "Save", "saved": "Numbering saved", "savedScheduled": "Saved. The new numbering starts on {{date}}.", "startSaved": "Starting number set", "revert": "Use the default", "reverted": "Back to the default numbering" }, "placeholders": { "YYYY": "Year, 4 digits", "YY": "Year, 2 digits", "MM": "Month", "FY": "Fiscal year", "SEQ": "Sequence" }, "refusals": { "pattern_empty": "Enter a pattern.", "pattern_bad_character": "Use only letters, digits and - / . _ outside the placeholders.", "pattern_unknown_placeholder": "Use only the placeholders offered, each in matching braces.", "pattern_seq_count": "A pattern needs exactly one sequence placeholder.", "pattern_seq_width": "The sequence takes 3 to 10 digits.", "pattern_too_long": "A number can be at most 40 characters.", "pattern_needs_year": "To restart every year or month, the pattern needs the year.", "pattern_needs_month": "To restart every month, the pattern needs the month.", "pattern_needs_fy": "A journal's pattern needs the fiscal year.", "pattern_fy_journal_only": "Only journals can use the fiscal year.", "reset_journal_fixed": "A journal always restarts each fiscal year.", "reset_fiscal_journal_only": "Only journals can restart each fiscal year.", "reset_unknown": "Choose when the count restarts.", "number_in_use": "The next number this pattern would issue is already used by another document.", "pattern_shared_with_journal": "Another journal already uses this pattern.", "period_started": "A number has already been issued this period, so the starting number can no longer be set.", "bad_start": "The starting number must be at least 1.", "no_fiscal_year": "Create a fiscal year covering today before setting a journal's starting number." }}Write ms/numbering.json and zh/numbering.json with the same keys, translated (Bahasa Malaysia is statutory — translate every value; keep {{placeholders}} identical). Run pnpm -C web test -- src/drift/i18nparity.test.ts; expect PASS.
- Step 2: Write the failing page test
web/src/features/numbering/NumberingPage.test.tsx:
import { http, HttpResponse } from 'msw';import { screen, waitFor } from '@testing-library/react';import { describe, expect, it } from 'vitest';import { server } from '@/test/msw/server';import { apiPath } from '@/test/msw/handlers';import { renderWithApp } from '@/test/renderWithApp';
const formats = [ { scope: 'expense_claim', kind: 'document', pattern: 'EC-{YYYY}-{SEQ:4}', reset: 'yearly', is_default: true, next_number: 34, next_preview: 'EC-2026-0034', can_set_start: false }, { scope: 'journal:GJ', kind: 'journal', journal_code: 'GJ', journal_name: 'General', pattern: 'GJ-{FY}-{SEQ:4}', reset: 'fiscal_yearly', is_default: false, next_number: 1, next_preview: 'GJ-2027-0001', can_set_start: true, pending: undefined },];
describe('NumberingPage', () => { it('lists every document type with its next number', async () => { server.use(http.get(apiPath('/org/numbering'), () => HttpResponse.json({ formats }))); await renderWithApp({ path: '/admin/numbering', permissions: ['org.numbering.manage'] }); expect(await screen.findByText('Expense claim')).toBeInTheDocument(); expect(screen.getByText('EC-2026-0034')).toBeInTheDocument(); expect(screen.getByText('Journal GJ — General')).toBeInTheDocument(); });
it('is forbidden without org.numbering.manage', async () => { server.use(http.get(apiPath('/org/numbering'), () => HttpResponse.json({ formats }))); // PRESENCE is the test above: the same path renders the list with the permission. await renderWithApp({ path: '/admin/numbering', permissions: [] }); await waitFor(() => expect(screen.queryByText('EC-2026-0034')).not.toBeInTheDocument()); });});Run: pnpm -C web test -- src/features/numbering
Expected: FAIL — route not found.
- Step 3:
api.ts,routes.tsx, nav, route tree
api.ts follows web/src/features/payout/api.ts exactly (keys object, useQuery + unwrap(await api.GET(...)), mutations invalidating ['numbering']). useNumberingPreview debounces its inputs 300 ms with the setTimeout-in-useEffect pattern from web/src/features/claims/ClaimsListPage.tsx:77-84, then runs useQuery({ queryKey: numberingKeys.preview(scope, debounced, reset), queryFn: …api.POST('/org/numbering/preview', { body: { scope, pattern: debounced, reset } }), enabled: debounced !== '', placeholderData: keepPreviousData }). The scope path parameter contains : for journals — pass it through params.path and let openapi-fetch encode it.
routes.tsx, copying web/src/features/payout/routes.tsx:
const numberingRoute = createRoute({ getParentRoute: () => appRoute, path: 'admin/numbering', component: () => ( <RequirePermission permission="org.numbering.manage"><NumberingPage /></RequirePermission> ),});export const routes = [numberingRoute];nav.ts, appended after the Workflows entry:
{ group: 'manage', to: '/admin/numbering', labelKey: 'numbering:nav', icon: Hash, permission: 'org.numbering.manage' },(import Hash from lucide-react). routes.ts: import { routes as numberingRoutes } from '@/features/numbering/routes'; and ...numberingRoutes, at the end of appRoutes.
- Step 4:
NumberingPage.tsx
Structure as web/src/features/payout/ChannelsPage.tsx: PageHeader (title/subtitle), DataTable<NumberingFormat> with columns document (scopeLabel), pattern (<code>; a muted Tag “Default” when is_default; a Tag “Changes on {{date}}” when pending), reset (t('numbering:reset.'+reset)), next (<code>{next_preview}</code>), onRowClick opens a Drawer titled editor.title containing <FormatEditor key={f.scope} format={f} onDone={close} />. renderCard shows label, next_preview and the reset for widths under 768 px. getRowId={(f) => f.scope}.
- Step 5: Write the failing editor test
web/src/features/numbering/FormatEditor.test.tsx:
import { http, HttpResponse } from 'msw';import { screen, waitFor } from '@testing-library/react';import { describe, expect, it, vi } from 'vitest';import { server } from '@/test/msw/server';import { apiPath } from '@/test/msw/handlers';import { renderWithProviders } from '@/test/render';import { FormatEditor } from './FormatEditor';
const claim = { scope: 'expense_claim', kind: 'document', pattern: 'EC-{YYYY}-{SEQ:4}', reset: 'yearly', is_default: true, next_number: 1, next_preview: 'EC-2026-0001', can_set_start: true } as const;const PERMS = ['org.numbering.manage'];
describe('FormatEditor', () => { it('inserts a placeholder at the cursor and previews the result', async () => { const seen: unknown[] = []; server.use(http.post(apiPath('/org/numbering/preview'), async ({ request }) => { const body = await request.json(); seen.push(body); return HttpResponse.json({ sample: 'EC-2026-09-0001', in_use: false }); })); const { user } = renderWithProviders(<FormatEditor format={claim} onDone={() => {}} />, { permissions: PERMS }); const input = screen.getByLabelText('Pattern') as HTMLInputElement; input.focus(); input.setSelectionRange(9, 9); // just after "EC-{YYYY}" await user.click(screen.getByRole('button', { name: /Month/ })); expect(input.value).toBe('EC-{YYYY}{MM}-{SEQ:4}'); expect(await screen.findByText('EC-2026-09-0001')).toBeInTheDocument(); await waitFor(() => expect(seen.length).toBeGreaterThan(0)); });
it('shows the translated refusal from the preview', async () => { server.use(http.post(apiPath('/org/numbering/preview'), () => HttpResponse.json({ in_use: false, error_code: 'pattern_needs_year', error_detail: 'x' }))); const { user } = renderWithProviders(<FormatEditor format={claim} onDone={() => {}} />, { permissions: PERMS }); const input = screen.getByLabelText('Pattern'); await user.clear(input); await user.type(input, 'EC-{{SEQ:4}'); // userEvent escapes "{" as "{{" expect(await screen.findByText('To restart every year or month, the pattern needs the year.')).toBeInTheDocument(); });
it('saves and reports a scheduled change', async () => { server.use( http.post(apiPath('/org/numbering/preview'), () => HttpResponse.json({ sample: 'C2701-001', in_use: false })), http.put(apiPath('/org/numbering/expense_claim'), () => HttpResponse.json({ scheduled: true, format: { ...claim, pending: { pattern: 'C{YY}{MM}-{SEQ:3}', reset: 'monthly', from: '2027-01-01' } }, })), ); const onDone = vi.fn(); const { user } = renderWithProviders(<FormatEditor format={claim} onDone={onDone} />, { permissions: PERMS }); // choose "Every month" in the reset select, then Save await user.click(screen.getByRole('combobox', { name: 'Restart the count' })); await user.click(await screen.findByRole('option', { name: 'Every month' })); expect(screen.getByText(/takes effect on/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(onDone).toHaveBeenCalled()); });
it('offers the starting number only while it can be set', async () => { renderWithProviders(<FormatEditor format={claim} onDone={() => {}} />, { permissions: PERMS }); expect(screen.getByLabelText('Starting number')).toBeInTheDocument(); // PRESENCE });
it('hides the starting number once the period has started', () => { renderWithProviders(<FormatEditor format={{ ...claim, can_set_start: false }} onDone={() => {}} />, { permissions: PERMS }); expect(screen.queryByLabelText('Starting number')).not.toBeInTheDocument(); });
it('offers journals only the fiscal-year reset', () => { renderWithProviders(<FormatEditor format={{ ...claim, scope: 'journal:GJ', kind: 'journal', journal_code: 'GJ', journal_name: 'General', reset: 'fiscal_yearly', pattern: 'GJ-{FY}-{SEQ:4}' }} onDone={() => {}} />, { permissions: PERMS }); expect(screen.queryByRole('combobox', { name: 'Restart the count' })).not.toBeInTheDocument(); expect(screen.getByText('Every fiscal year')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Fiscal year/ })).toBeInTheDocument(); });});Run: pnpm -C web test -- src/features/numbering
Expected: FAIL — FormatEditor does not exist.
- Step 6:
FormatEditor.tsx
Built like web/src/features/payout/ChannelEditor.tsx (react-hook-form + zod; Field/Input/Select; Controller around Select):
-
Pattern
Input(labeleditor.pattern, hinteditor.patternHint), with arefkept viaform.register’s ref merged with a localuseRef<HTMLInputElement>. -
Chips: one
Button variant="ghost" size="sm"per placeholder allowed for the scope — documents{YYYY} {YY} {MM} {SEQ:4}, journals{FY} {MM} {SEQ:4}—aria-labelfromplaceholders.*. On click: readselectionStart/selectionEnd, splice the token into the value,form.setValue('pattern', next, { shouldDirty: true }), thenrequestAnimationFrame(() => el.setSelectionRange(pos, pos))withposafter the inserted token, and focus the input. -
Reset: for documents a
Selectofyearly,monthly,never(labeleditor.reset); for journals plain textreset.fiscal_yearly, no control. -
Preview:
useNumberingPreview(scope, pattern, reset). Showsamplein<code>; iferror_code, showt('numbering:refusals.'+error_code, { defaultValue: error_detail })in the error styleFielduses; ifin_use, showeditor.previewInUse. -
Scheduled note: when
reset !== format.reset, showeditor.scheduledNotewith 1 January of next year, formatted with the app’s date formatter (use the same oneClaimsTableuses for dates). -
Pending note: when
format.pending, showeditor.pendingNote. -
Save:
useSaveNumberingFormat; on success toasteditor.savedoreditor.savedScheduledandonDone(); on error, ifProblemErrorhascodein the refusal list, shownumbering:refusals.<code>under the pattern; elseproblemMessage(e, t)toast. -
Use the default: a secondary button shown when
!format.is_default, callinguseResetNumberingFormat. -
Starting number: only when
format.can_set_start— a numberInput(labeleditor.start, hinteditor.startHint,min=1, integer) and aSet starting numberbutton callinguseSetNumberingStartwith an integer fromNumber.parseInt(this is a count, not money; the money rule does not apply, but still refuse non-integers in the zod schema). -
Step 7: Run the web tests
Run: pnpm -C web test -- src/features/numbering src/drift && pnpm -C web typecheck && pnpm -C web lint
Expected: PASS.
Watch one fail: make the chip append at the end instead of at the cursor; expect the first editor test to FAIL with EC-{YYYY}-{SEQ:4}{MM}. Restore.
- Step 8: Runtime check against the dev stack
Start the stack as the repo’s README/scripts/dev-stack.sh says (it is already running from another worktree; point the preview at this worktree’s web dev server). As owner@demo.test open /admin/numbering at 1280 px and 390 px, light and dark, in en and ms:
- the list shows seven document types and every journal with today’s numbers;
- editing Expense claim to
CLM/{YY}{MM}/{SEQ:4}previewsCLM/2609/<next>; switching to Every month shows the 1 January note; - cancel without saving (the dev org is shared); if you saved, press Use the default afterwards and confirm the list shows
EC-2026-…again.
Take a screenshot of the list and the editor at each width for the task report.
- Step 9: Commit
git add web/src/features/numbering web/src/lib/i18n/locales/*/numbering.json \ web/src/lib/api/types.ts web/src/app/shell/nav.ts web/src/app/routes.tsgit commit -m "web: Document numbering admin page — patterns, preview, scheduled resets, starting number
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 6: End to end, the full run, roadmap and deploy notes
Section titled “Task 6: End to end, the full run, roadmap and deploy notes”Files:
- Create:
web/e2e/numbering.spec.ts - Modify:
docs/ROADMAP.md(tick “Plan and build (Plan 12)” and the item) - Modify:
docs/DEPLOY_NOTES.md(+ Plan 12 section)
Interfaces:
-
Consumes:
web/e2e/support.ts—as,api,runTag;POST /expenses/claimswith{ title }, which returns the claim with itsreference. -
Step 1: Write the spec
web/e2e/numbering.spec.ts:
import { expect, test } from '@playwright/test';import { api, as, runTag } from './support';
// Owner changes the expense-claim layout through the screen; the employee's// next claim carries it. The dev org is shared by every spec (workers: 1),// so the default is restored however the test ends.test.describe('document numbering', () => { test.afterEach(async () => { const owner = await api('owner'); await owner.DELETE('/org/numbering/{scope}', { params: { path: { scope: 'expense_claim' } } }); });
test('a new pattern numbers the next claim', async ({ browser }) => { const owner = await as(browser, 'owner'); await owner.goto('/admin/numbering'); await owner.getByText('Expense claim').click(); const pattern = owner.getByLabel('Pattern'); await pattern.fill('CLM/{YY}{MM}/{SEQ:4}'); const preview = owner.getByTestId('numbering-preview'); // add data-testid on the preview <code> await expect(preview).toHaveText(/^CLM\/\d{4}\/\d{4,}$/); const previewed = await preview.innerText(); await owner.getByRole('button', { name: 'Save' }).click(); await expect(owner.getByText('Numbering saved')).toBeVisible();
const emp = await api('employee'); // The body the claim wizard's first save sends (web/src/features/claims/wizard/useClaimDraft.ts:40). const created = await emp.POST('/expenses/claims', { body: { title: `${runTag()} numbering` } }); expect(created.error).toBeUndefined(); expect(created.data!.reference).toBe(previewed); });});The preview’s <code> gets data-testid="numbering-preview" in FormatEditor.tsx — add it in this task and mention it in the commit.
Note: previewed equals the created reference only if nothing else creates a claim in between; the e2e run is single-worker, so this holds.
- Step 2: The one full run
Run, in order, and record each result for the report:
go test -p 2 -count=1 ./...make lintmake generate && git status --porcelain(expect no output: generated code matches its sources)
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web testpnpm -C web e2eIf an e2e flow fails between 00:00 and 08:00 MYT on the pre-existing UTC-vs-MYT expense-date issue (noted during Plan 11), record it as that and do not “fix” it here.
- Step 3: Roadmap and deploy notes
docs/ROADMAP.md: tick - [ ] Plan and build (Plan 12) and the parent item, and add the plan link under the Design line:
Plan: [`2026-09-25-plan-12-document-numbering.md`](superpowers/plans/2026-09-25-plan-12-document-numbering.md) (6 tasks)docs/DEPLOY_NOTES.md, a new section in the file’s existing style:
## Plan 12 — Document numbering
- Migrations `01100` (table `numbering_format`) and `01101` (grants `org.numbering.manage` to every existing Owner and bumps `perm_version`, so each live session refreshes once).- No data changes: an organization that configures nothing keeps its exact numbers, and every existing counter carries on.- `org.numbering.manage` can change journal entry layouts. It is granted to Owner only; grant it to others deliberately.- Step 4: Commit
git add web/e2e/numbering.spec.ts web/src/features/numbering/FormatEditor.tsx docs/ROADMAP.md docs/DEPLOY_NOTES.mdgit commit -m "Plan 12: numbering end to end; roadmap and deploy notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Spec coverage
Section titled “Spec coverage”| Spec | Task |
|---|---|
| N1 one pattern per type | T1 Scope, T2 numbering_format PK |
| N2 placeholders, N3 one SEQ | T1 parse/Validate |
| N4 reset rules and requirements | T1 Validate, periodKey |
| N5 FY journal-only | T1 pattern_fy_journal_only, reset_fiscal_journal_only |
| N6 journals fiscal-yearly | T1 reset_journal_fixed, T3 ledger |
| N7 counter continues | T2 Set + test |
| N8 reset change on 1 Jan | T2 Set + test, T5 note |
| N9 starting number once | T2 SetStart, T4 /start, T5 field |
| N10 default = today | T1 Register, T2 test, T3 unchanged reference tests |
| N11 issued never change | nothing rewrites a reference; T3 changes only issuance |
| N12 backstop | R3: unique constraint + T2/T4 in-use refusal + preview in_use |
| N13 permission | T4 module, migration 01101 |
| N14 one function | T3 Issue-only + bypass guard |
| §5 API | T4 |
| §6 screen | T5 |
| §7 drift 1–3 | T2 (RLS), T3 (bypass, registry) |
| §7 end to end | T6 |