Plan 7 — Advances 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: A member asks for a cash advance (purpose, amount, due date), it is reviewed, approved and paid out through an external payout channel against a sealed settlement snapshot — posting Dr Employee Advances / Cr bank before any expense is recognised (payout_first) — and later acquitted against the real receipt through spec §7.3’s pure decision table: the acquittal accrues the actual to the receipt’s expense account, and a signed variance starts an advance_topup (overspend, paid only after an approver signs off on the excess) or an advance_refund (underspend, confirmed when the cash is back), while an acquittal of zero returns the whole advance; every posting carries source_id = <advance id>, the Employee Advances control nets to zero on every closed advance and two reconciliation checks prove it, Plan 9 gets ListForAging, and overdue acquittals are computed as data with an escalation level (delivery deferred).
Architecture: One platform package and one module. internal/platform/advances owns everything with an advance rule in it: the three workflow graphs (registered for seeding from its own init() through the base branch’s workflow.RegisterSeededGraph), the header, the acquittal record and its receipt binding (a documents.Binder), the pure acquittal decision function, the transition service around workflow.Transition (branch by variance sign, start the variance leg in the acquittal transaction, refuse petty cash, require the overspend approval), sealed settlements, four posting rules, two reconciliation checks, ListForAging, the reminder computation, its own idempotency store, and the snapshot reseal. internal/modules/advances is the HTTP shell for /v1/advances/… and registers the posting rules at boot. The database enforces what must never drift: draft-only header edits, an acquittal written only while the advance sits at received and frozen once decided, §7.3’s decision table as named CHECKs, a variance leg that matches the sign of the variance, append-only sealed settlements.
Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL, sqlc models only) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money
Specs (read before any task):
docs/superpowers/specs/2026-09-22-phase1-wave3-contracts.md— BINDING. §1 (the base-branch seam this plan consumes), §2.1 (this plan’s shape), §3 (ownership and ranges), §4 (deferrals), §5 (lessons), §6 (merge order; migrations apply out of order and must commute), §7 (amendments from the base build), §8 (amendments from plan writing, accepted by the controller on 2026-09-22) — read the copy onphase1-wave3. Where this plan departs from the original §2.1 it says so in a Ruling marked deviation (accepted, contract §8.1) and in Contract departures (accepted).docs/superpowers/specs/2026-09-21-phase1-wave2-contracts.md§3 and2026-09-21-phase1-wave1-contracts.md§3, §5 — still binding.docs/superpowers/specs/2026-09-20-erp-architecture-design.md— §7 in full, §5.5, §5.7, §10.3, §10.6, §11A, §20.docs/superpowers/plans/2026-09-21-plan-5-expense-claims.md— the house pattern this plan copies (settlement, transition wrapper, idempotency, visibility, routes, reseal) and its merged code underinternal/platform/expensesandinternal/modules/expenses.- Plan 9’s Deferred until Plans 7/8, D1 (
git show plan-9-reports:docs/superpowers/plans/2026-09-21-plan-9-reports.md) — theAgingRefshape. - The base branch
phase1-wave3is built (at1d9ddab, including the contract’s amended §6 “migrations apply out of order” and §7 “amendments from the base build”). Every contract §1 symbol this plan uses was read on that branch while writing it; the Reconciliation record lists each with itsfile:line. This branch is reset ontophase1-wave3before building, and Task 1 is the gate that confirms nothing moved.
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Go floor is 1.26.0. CI derives its version from
go.mod; never restate one. - Ownership (wave-3 contract §3). Go code only under
internal/platform/advances/…andinternal/modules/advances/…. Never editinternal/platform/{posting,workflow,documents,expenses,payout,accounting}orinternal/modules/expenses, or any base-branch file. From other plans, import only:workflow(the engine API and the contract §1.2–1.3 additions),posting,accounting(PostingRule,Source,Draft,NewPoster,NewRuleSet,RegisterPostingRule),ledger,chart,fx,reconcile,documents(Get,Lock,StatusVerified,ErrNotFound, and the binder API:Binder,Binding,RegisterBinder,BoundElsewhere,MayRead),expenses(GetCategory,UsableAccount,CheckRecordText— Ruling A17),taxrecord(the §6.7 rules — Ruling A30),payout(GetChannel,Snapshot,SettlementSnapshot,Names,BehaviourPettyCash,ResealSnapshot, the typed refusals),orgstruct.SuperiorOf,numbering,company,member,activity,audit. Plan 7 never callspayout.OpenSnapshot(Ruling A18; a guard test enforces it). - Migrations
00130–00149only; this plan uses00130–00131. They must commute with every higher-numbered migration (contract §6: goose runsupwith allow-missing, so Plan 8’s00150+ may be applied before them and Plan 9’s00110+ after them):00130–00131create only Plan 7’s own tables, functions and triggers, and redefine nothing another range owns. Provisioning step orders110–119are reserved and unused (Ruling A29). Permissionsadvances.*only. OpenAPI paths/advances/…only (served under/v1). Workflow kindsadvance,advance_topup,advance_refundonly. Reconcile keysadvance_control,advance_closed_nets_zeroonly. Document binder kindadvanceonly. - Shared files are append-only (wave-1 §3, wave-3 §3):
constants.yamlpermissions list,api/openapi.yaml,sqlc.yaml(column overrides),internal/app/app.go,internal/app/routes_test.go(composedModules), the module imports incmd/api,cmd/migrate,cmd/orgadmin,cmd/workerand theirwiring_test.go,cmd/migrate/reseal.go(Task 9 inserts its advance lines; it rewrites none of Plan 5’s),internal/kernel/db/retention_test.golists,docs/DEPLOY_NOTES.md(a new section on top),docs/ROADMAP.md(tick only Plan 7’s lines). Generated files are regenerated withmake generate, never hand-merged. - Tenant isolation, both layers. Every new table:
org_id UUID NOT NULL,ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid. Every SQL statement namesorg_id = $nfor each org-scoped table it touches (join equalities count) and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Every FK between two org-scoped tables is composite(org_id, x) REFERENCES t (org_id, id)(TestEveryTenantForeignKeyIsCompositeOnOrgID). - Named constraints (wave-2 §3). Every
CHECK,UNIQUEandEXCLUDEis writtenCONSTRAINT <name> …; no name ends in_check,_keyor_excl(Plan 5’sconstraintnames_test.go). A trigger’s refusal names its constraint withUSING CONSTRAINT = '<name>'. Error mapping is keyed on these names. - Pinned functions (wave-2 §3). Every function or trigger function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table (public.advance).TestEveryFunctionPinsItsSearchPathenforces the first half. - No
SET CONSTRAINTSin non-test Go. - Every table is in exactly one list in
internal/kernel/db/retention_test.go.advanceandadvance_acquittalareamendable(DELETE revoked);advance_settlementisappendOnly(UPDATE revoked at table level,UPDATE (snapshot)granted for the reseal only);advance_idempotency_keyisoperational. - Lock order, one for the whole plan (wave-2 §3, wave-3 §5 and §7): idempotency-key row →
advancerow (FOR UPDATE) →advance_acquittalrow (FOR UPDATE) →documentrow (documents.Lock, binding path only; the consumer’s own record first, then the document, thenBoundElsewhere— contract §7) →workflow_instancerow (taken insideworkflow.SetAmount/workflow.Transition) →audit.Write. This plan adds no advisory-lock class. - Never write an audit row before posting (wave-2 §3).
workflow.Transitionruns before anyaudit.Writeof this plan’s in the same transaction;workflow.Start(which audits) runs afterworkflow.Transitionin the acquittal transaction and in an earlier transaction than the started instance’s firstTransition(wave-3 §5). - Commit-time errors (wave-2 §3). Every handler whose transaction can post passes
db.InTx’s error throughledger.MapErrorbefore mapping it to HTTP. - One org per transaction. Every platform function reads the org from
tenancy.OrgFromContext; nothing takes an org argument. - No nested
db.InTx. Platform functions take(ctx, tx pgx.Tx, …). Only handlers and the reseal command open transactions. - Money is
money.Amount(NUMERIC(19,4)); exchange rates aremoney.Rate(NUMERIC(19,10)). Neverfloat64; never compare amounts with==(useCmp). On the wire both are strings (the OpenAPIMoneyandRateschemas). - The business date is
clock.TodayUTC(c); everyDATEhandled in Go is midnight UTC. Nevertime.Now()in code under test. - Record-bearing free text (advance purpose, acquittal reason, supplier name, relaxation reason) is English or Bahasa Malaysia (§11A.3):
expenses.CheckRecordTextin Go and a namedCHECKusing!~ '[-ヿ㐀-䶿一-鿿가-]'(written out in full at each use;<cjk>below stands for it). - Every posting has a path back to a document and a decision (§11A.4, §11A.7, wave-3 §5). Every posting-rule test asserts it by walking from the journal entry’s
source_idto the advance, its document(s) and the deciding member inworkflow.Decisions. - Errors are RFC 7807. Handlers
c.Error(apperr.New(kind, detail))and return. Platform code returns sentinel or typed errors, never*apperr.Problem. A field refusal travels as a 422 whose detail isfield=<name> rule=<code>: <sentence>(Plan 5 R43’s shape). - Every route goes through
httpx.Protectedwith anadvances.*permission registered in the module’sinit(), and is documented inapi/openapi.yamlin the same commit (internal/app/routes_test.go). postingandaccounting.DefaultRules()are process-global. Tests that need a Poster callposting.ResetForTest()first and int.Cleanup(the fixture does), registeraccounting.NewPoster(clock, rs)over a freshaccounting.NewRuleSet()holdingadvances.Rules(), and never uset.Parallel().- Presence before absence (
AGENTS.md). Every refusal test shows the accepted case first; every guard is watched failing once (a Sabotage step) and its red output is quoted in the task report. - Concurrency tests bound every wait with
context.WithTimeoutand clean up int.Cleanup. - Tests: during development run only the packages you changed (
go test ./internal/platform/advances/... -count=1).make generateafter any migration, OpenAPI orconstants.yamlchange. One fullgo test -p 2 -count=1 ./...in Task 16 only. - Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”Decisions taken where the specs are silent, ambiguous, or cannot be built as written. Format: Ruling: <decision> — <why> — <cost if wrong>. Rulings marked deviation (accepted) depart from the original contract §2.1 or the spec; the controller accepted each on 2026-09-22 and contract §8.1 now states them. They are repeated under Contract departures (accepted) at the end.
- A1 Ruling: Packages are
internal/platform/advances(+advances/advancestest, a non-test fixture package likeexpensestest) and one moduleinternal/modules/advances— contract §2.1 and §3 fix these paths and Plan 9 D1 importsinternal/platform/advances— none. - A2 Ruling:
advances.Kind = "advance",KindTopup = "advance_topup",KindRefund = "advance_refund", andadvances.SourceKinds = []string{Kind, KindTopup, KindRefund}exactly as contract §2.1 exports it; every posting of all three kinds carriessource_id = <advance id>because each variance leg’s workflow instance is started withSourceID: advance.ID— contract §2.1; Plan 4’s Poster setsjournal_entry.source_kindto the workflow kind andsource_idto the instance’s source id (poster.go:118-124) — none. - A3 Ruling (deviation, accepted — contract §8.1): Each variance-leg graph starts in its own
draftstate (advance_topup:draft → topup_pending → …;advance_refund:draft → refund_pending → …). The acquittal transaction starts the leg indraft; the claimant opens it (draft → topup_pending/draft → refund_pending, reporter gate) in a later transaction — the validator’s rule 1 requires exactly onedraft-semantic state (validate.go:228-248, codedraft_count) andworkflow.Startrefuses a definition without one (instance.go:171-179,errNoDraftSemantic); contract §2.1’s leg graphs have none, and contract §1.2 does not relax rule 1 forcash_only. It also gives wave-3 §5’s “start in an earlier transaction than the firstTransition” a natural resting state — one more click for the claimant per variance. - A4 Ruling (deviation, accepted — contract §8.1): An acquittal of zero does not enter
acquitted. The advance graph has a terminalreturnedstate (semanticcancelled, target gate, OWNER-seat grant, reversal flag set) reachable fromreceived; entering it makes the engine mirror the advance’s payout (Dr <the account the payout credited> / Cr Employee Advances), which is exactly “the whole advance comes back” (§7.3), confirmed by the member who takes the edge. Noadvance_refundleg is started for a zero acquittal —acquittedis an accrual state, and an accrual at 0 cannot post:ledger.Postrefuses a line whose debit and credit are both zero (ledger/post.go:234) and the Poster has no “post nothing” path (poster.go:106-130), so the transition would abort; contract §2.1’s table (accrual “atactual”) and the spec’s= 0row cannot both hold. Rule 3 permits it because reversing states are removed from the graph (validate.go:253-262) and contract §1.1 makes a cancel after payout reverse — the cash-in for a zero acquittal posts through the engine’s reversal rather than a refund rule, so it debits the payout’s own credit account; finance must deposit the returned cash there. - A5 Ruling (deviation, accepted — contract §8.1): A dispute raised after acquittal uses its own states:
acquitted → acquittal_disputed(reporter, reason) andacquittal_disputed → acquittal_confirmed(dismiss, source gate, OWNER seat;acknowledged, terminal;acquittal_confirmed → acquittal_disputedmay be raised again). No uphold edge is seeded fromacquittal_disputed. Dismissing back intoacquittedputs an accrual on a cycle (rule 3(a),accrual_on_cycle); upholding intorejectedwould make the engine mirror every unreversed entry of the advance instance — the payout as well as the accrual — which records cash the claimant really holds as returned, and it cannot reach the variance leg’s entries, so the control would not net to zero. Correcting an acquitted figure is deferred to a later adjustment document kind (controller ruling; contract §8.1; Notes for later plans) — the spec says “disputes are reachable from every post-payout state” and a dispute is reachable; only its uphold is withheld. - A6 Ruling (deviation, accepted — contract §8.1):
advance_refundgainsrejected(uphold target, reversal flag) andrefund_settled(acknowledged, terminal, the dismiss target):refund_confirmed → disputed,disputed → refund_settled(dismiss),disputed → rejected(uphold, reverses the refund),rejected → draft(revise),refund_settled → disputed. Dismissing back intorefund_confirmedputs the payout state on a cycle (payout_on_cycle), and contract §2.1 gives the refund kind no reversing state for an upheld dispute to enter — two more states on the refund graph. - A7 Ruling: The seeded graphs’ gates and grants (all floors nil, i.e. every amount): advance —
draft → pending_reviewreportersubmit;pending_review → reviewedtarget (grant onreviewed:superior);reviewed → approvedtarget (approved:unit_head);approved → paid_outtarget (paid_out: OWNER seat);paid_out → receivedreporteracknowledge;received → acquittedtarget (acquitted: OWNER seat);received → returnedtarget (returned: OWNER seat);pending_review|reviewed|approved → rejectedadvance gatereject;rejected → draftreporterrevise;draft → cancelledreporterwithdraw;paid_out|received → disputedreporter;disputed → receivedsourcedismiss,disputed → rejectedsourceuphold(reverses) (disputed: OWNER seat); A5’s two dispute edges (acquittal_disputed: OWNER seat). advance_topup —draft → topup_pendingreportersubmit;topup_pending → topup_approvedtargetapprove_excess(topup_approved:unit_head);topup_approved → topup_paidtargetpay(topup_paid: OWNER seat);topup_paid → topup_receivedreporteracknowledge;topup_pending|topup_approved → rejectedadvancereject;rejected → draftreporterrevise;topup_paid|topup_received → disputedreporter;disputed → topup_receivedsourcedismiss,disputed → rejectedsourceuphold(reverses) (disputed: OWNER seat). advance_refund —draft → refund_pendingreporterdeclare;refund_pending → refund_confirmedtargetconfirm(refund_confirmed: OWNER seat); A6’s edges (disputed: OWNER seat) — the same authorities Plan 3 seeded forexpense_claim(superior reviews, unit head approves, the root seat pays and verifies), which is what provisioning guarantees exists — an organization re-grants by publishing its own version. - A8 Ruling: The claimant records the acquittal (
PUT /v1/advances/{id}/acquittal: actual, receipt, date, category or reason) while the advance is atreceived; a member holding theacquitted/returnedgrant (the OWNER seat by default) decides it by takingreceived → acquittedorreceived → returned— §7.2 “they supply the real receipt and the real amount” (the recording), while §11A.4 needs “the person who approved it” behind the accrual, and Plan 3’s seed keeps “never accrue to an account no human has verified”; the engine’s self-approval refusal (RefuseSelfApproval) stops a claimant verifying their own — one extra decision per advance. - A9 Ruling: Branching by variance sign is enforced three times: (1) the transition service runs
DecideAcquittalon the stored acquittal and refuses the mismatched exit (ErrAcquittalBranch, 422:acquittedneeds outcomesettled/topup/refund,returnedneeds outcomereturned); (2) it starts only the matching leg (advance_topupfor a positive variance,advance_refundfor a negative one with actual > 0, none for zero); (3) the triggeradvance_variance_leg_guardrefuses a leg id whose instance kind, source or the acquittal’s sign disagree. The read API marks the mismatched exitallowed: falsewith refusal codeacquittal_branch— the engine has no guards (Plan 3 Ruling 2) andAvailableTransitionslists both exits, the same position contract §2.2 takes for Plan 8’s pay edge — none. - A10 Ruling: Entering a payout state of
advance_topuprequires the overspend approval:OverspendApproved(decisions, draftKey, reporter)— true only when, since the leg last left its draft state, some decision wastarget-gated and taken by (and on the authority of) someone other than the reporter. In the seeded graph the engine’s edges already forcetopup_approvedbeforetopup_paid; the check keeps §7.3’s “repaid only after an approver signs off on the excess” true for any version an organization publishes, because accounting reacts to semantics, never state names — a legitimate org graph that pays an overspend with no target-gated step is refused (ErrOverspendNotApproved, 409). - A11 Ruling: Everything that posts is in the organization’s base currency. The header records the requested
currency/amountand freezesfx_rate,fx_rate_sourceandbase_amountfromfx.Resolve(currency → base, TodayUTC)at save (re-resolved only when a draft edit changes amount or currency); the acquittal recordsactual_currency/actual_amountand freezesactual_fx_rate,actual_fx_rate_source,actual_basefromfx.Resolve(actual_currency → base, receipt_date)(a zero acquittal is in base at the identity rate). The instance amount, the settlement, the payout, the accrual, the variance and both legs are in base — Plan 5 R5’s pattern: base frozen at the defining event (§6.5), and Plan 3’s instance amount is base-only — an advance paid in a foreign currency needs an FX-difference path later. - A12 Ruling:
due_on(the acquittal due date, Plan 9 D1’sDueOn, §7.6) is supplied by the claimant at creation, must lie in[TodayUTC, TodayUTC + 366 days], is editable only in draft, and is re-checked at submission (a draft left to go stale cannot be submitted with a past due date) — contract §2.1 requires the date and no organization setting exists to derive it — an approver who disagrees with the date rejects the advance back to draft. - A13 Ruling: References are
AD-YYYY-NNNNfromnumbering.Allocate(ctx, tx, "advance", year)andnumbering.Format("AD", year, n), calendar year ofTodayUTCat creation, inside the creating transaction — contract §2.1, §20.1.3 — none. - A14 Ruling: The advance stores
holder_party_id, the claimant’sorganization_member.party_idread at creation (composite FK toparty);AgingRef.HolderNameis that party’slegal_name— Plan 9 D1 asks forHolderPartyIDand a record-language name, and Plan 5 postsPartyID= the claimant’s party — none. - A15 Ruling: An acquittal has one receipt document, the column
advance_acquittal.receipt_document_id(partial unique indexadvance_acquittal_receipt_once— this consumer’s own one-record rule, contract §1.4). The binding path locks advance → acquittal → document (documents.Lock), then callsdocuments.BoundElsewhere(…, except Binding{Kind: "advance", RecordID: advance.ID})and refuses any binding it returns (*DocumentBoundElsewhereError, wrappingErrDocumentBoundElsewhere, 409 whose detail names only the kind of each record — contract §8.3; theBindingsstill carryRecordIDandReferencefor logs and tests, never for the wire). Replacing the receipt whilereceivedreleases the old document (the column changes; the audit row records both ids). The advance binder also reports a document used as an advance or top-up settlement proof as bound, and settling with a proof does not callBoundElsewhere— contract §7 (own record first, then the document, thenBoundElsewhere; “a settlement proof counts as bound”), matching the builtexpensesbinder (internal/platform/expenses/binder.go) — a multi-receipt acquittal uploads one combined PDF (a child table later is additive). - A16 Ruling: The acquittal row is written only while the advance’s instance is in an
acknowledged-semantic state with the payout open and no accrual open (i.e.received) and never onceacquitted_atis set — enforced by the triggeradvance_acquittal_guardas well as in Go; the acquittal transaction setsacquitted_at,acquitted_byandexpense_account_idbeforeworkflow.Transition(while stillreceived), so a transition that fails rolls them back — Plan 5 R16’s reasoning: a Go-only rule is one forgotten call away from a mutated accrued figure — the trigger couples toworkflow_instance.payout_open(contract §1.1), which Task 1 verifies. - A17 Ruling: The acquittal’s category is an organization
expense_category(Plan 5’s registry, composite FK); its account is resolved withexpenses.UsableAccountwhen the acquittal is recorded and again, then frozen intoexpense_account_id, when it is decided;advancesimportsinternal/platform/expensesforGetCategory,UsableAccountandCheckRecordText— one category list and one account mapping for every expense the org recognises, andexpensesimports nothing ofadvancestoday — whoever enablesadvance_offsetinsideexpensesmust not importadvancesback (see Notes for later plans). - A18 Ruling:
advance_settlementholds one row per payout of theadvanceoradvance_topupkind (columnkind,instance_id):payout.Snapshot’s sealed JSON byte-for-byte, the channel’sCreditAccountIDread then, amount, currency, proof document, actor,txid. The claim-type key passed to Plan 6 is the workflow kind ("advance","advance_topup"), Plan 5 R41’s convention. Channels areexternalonly: apetty_cashchannel is refused insettle(ErrChannelPettyCash, 422 namingpayout_channel_id) and again in both payout rules (ErrPettyCashDeferred). Plan 7 never callspayout.OpenSnapshot;TestPlan7NeverOpensASnapshotguards both packages. The table carries Plan 5’s three sealed-snapshot CHECKs and a reseal-only trigger — contract §2.1 (“external channel only”, “must add its table tomigrate reseal-payout-details”), Plan 5 R24/R25/R44/R45 — no advance paid from the tin until the follow-up contract §4 names. - A19 Ruling: The refund posts
Dr <the account the advance's unreversed payout entry credited> / Cr <the control that payout debited>on the active journal whose default account is that debit account, else the defaultbankjournal; it has no channel and no snapshot — money coming in has no payee details to freeze, and returning cash to the account it left keeps the bank reconciliation one-to-one — a refund banked elsewhere needs a later “refund to account” choice. - A20 Ruling: Every leg after the payout — the accrual, the top-up, the refund — reads the Employee Advances account from the advance’s unreversed payout entry (its one debit line), not from a freshly resolved control;
Source.Control(resolved by the Poster fromControlKindon the accrual) is not used for the credit — Plan 5 R26’s argument turned round for a payout-first flow: an organization re-pointing its control between payout and acquittal must still see this advance net to zero on the account it was issued against — none. - A21 Ruling: The acquittal transaction runs, in order: idempotency key →
advanceFOR UPDATE→advance_acquittalFOR UPDATE→ decide and freeze the acquittal row →workflow.Transition(instance lock,posting.Dispatch, its audit) →workflow.Startof the leg (its audit) →UPDATE advance SET topup_instance_id|refund_instance_id→ the advance’s own audit and activity. ThatUPDATEis the one write after an audit row; it touches the advance row this transaction already holds and its FK check reads only the leg instance this transaction just inserted, so it waits on nothing — the order wave-2 §3 and wave-3 §5 fix, andworkflow.Startmust followTransitionbecause it audits — none. - A22 Ruling: An advance is visible to its claimant, to holders of
advances.advance.read_all, to members with an allowed transition on any of its three instances now, and to members in any of their decision logs; anyone else gets 404.GET /advances?scope=to_actreturns at most 200, oldest-in-state first, without a cursor — Plan 5 R21 extended over the legs — a large org’s queue needs an index-backed query later. - A23 Ruling:
Idempotency-Keyis required onPOST /v1/advances/{id}/transitions, stored in this plan’s ownadvance_idempotency_keytable (scoped(org, member, key); same key with a different request hash is 422; only a successful response is stored; operational, no pruner) — contract §3 gives each plan its own table, and sharing Plan 5’s would make one key namespace span two modules — the table grows until a pruner lands. - A24 Ruling:
PATCH /advances/{id}andPUT /advances/{id}/acquittalrequireIf-Match: "<revision>"(400 missing, 409 stale);GETreturnsETag. The revision moves on a header edit and on every acquittal save, not on a transition (Plan 5’s pattern: the transition’s concurrency control isfromplus the key) — none. - A25 Ruling: Four permissions, appended to
constants.yaml:advances.advance.create(file an advance, edit its draft, record its acquittal),advances.advance.read,advances.advance.read_all(every advance, and the reminder listing),advances.advance.transition(use the transition endpoint; authority still comes from grants) — Plan 5 R37’s split — none. - A26 Ruling: Two reconciliation checks, registered from
internal/platform/advances’sinit():advance_control— per advance, the base balance on accounts of control kindemployee_advances, summed over entries whosesource_kindis inSourceKindsandsource_idis the advance, equals the module’s expected outstanding:base_amount·[advance payout open] − actual_base·[advance accrual open] + topup.amount·[top-up payout open] − refund.amount·[refund payout open]; plus one finding per control-account line whose entry is not an advance kind (the control carries nothing but advances).advance_closed_nets_zero— every closed advance (its instance in a terminal state and its leg, if any, in a terminal state) has a zero base balance on the control across the three kinds. Each finding names the advance (or account) and both figures — §10.6 “Advances control”, contract §2.1 “nets to zero … across the three kinds” — a failing check blocks period close (Plan 4’s precondition), which is intended. - A27 Ruling:
ListForAging(ctx, tx, asOf)returns every advance of the calling org with at least onepayoutentry of kindadvancedated on or beforeasOf,IssuedOn= the earliest such entry’s date (a reversed-and-repaid advance keeps its first issue date), ordered byIssuedOn,Reference; it returns reversed advances too, because Plan 9 drops zero-balance ones with its ownHAVING— Plan 9 D1’s text (“every advance issued on or before asOf”) and itsAgingReffield list, verbatim — none. - A28 Ruling: Reminders are computed, not delivered (contract §4).
Overdue(ctx, tx, asOf)lists every advance whose payout is open, whose accrual is not, which is not in a reversing state, and whosedue_onis beforeasOf, withDaysOverdue,Level(claimantfor 1–7 days,managerfrom day 8:ManagerEscalationAfterDays = 7) andRecipients(the claimant, plusorgstruct.SuperiorOf(claimant, asOf)at manager level;ManagerUnresolvedwhen that is empty). Served asGET /v1/advances/reminders?as_of=(advances.advance.read_all) and as anoverdueblock on the advance read. No River, no mailer, no job: the ROADMAP sub-item “Acquittal reminders” stays unticked with a note — River lands with Plan 9, no mailer exists, and the worker cannot enumerate tenants under RLS (Plan 4 R14) — the seven-day threshold is confirmed by the controller as a named constant (ManagerEscalationAfterDays); changing it is a one-line change. - A29 Ruling: Plan 7 registers no provisioning step: its three graphs reach every org through step 60 (
workflow_definitions), which seeds any kind with no version (contract §1.3), and its categories and channels are Plan 5’s and Plan 6’s; existing orgs gain the kinds on the nextPOST /v1/org/provision(DEPLOY_NOTES) — none; orders 110–119 stay reserved. - A30 Ruling (controller ruling 2026-09-22, contract §8.1): The acquittal captures the §6.7 tax record —
document_basis,supplier_name,supplier_tin,einvoice_uuid, andrelaxation_reason(which therelaxation_receiptbasis requires) — with exactly a claim receipt line’s rules. They are validated by the base’s new database-free packageinternal/platform/taxrecord(extracted fromexpenses’normalizeTaxRecord), never copied: at recording, the shape (identifier patterns after upper-casing, e-Invoice identifiers present for an e-Invoice basis, a reason for a relaxation receipt, a known basis, record-text language) and the category rule for an employee-named e-Invoice; at the decision (received → acquitted), completeness — a basis is present when anything was spent (Plan 5 R14’s submission half). A zero acquittal carries no tax record at all.00130mirrors00072’s named CHECKs one for one, each citing the constraint it repeats — acquittal is where the expense is recognised and its receipt is the tax document (§6.7, §11A.6), and identifiers cannot be backfilled onto settled documents — the taxrecord API names are expected, not yet built; Task 1 reconciles them. - A31 Ruling:
advance_offsetstays deferred (contract §2.1, Plan 5 R11): an advance settles only through its own acquittal — none. - A32 Ruling: An approver who refuses the excess rejects the top-up (
topup_pending|topup_approved → rejected); the leg’s only way on isrejected → draft(revise and resubmit). While it sits there the control carries the excess as owed to the claimant, the advance is not closed,advance_controlpasses (the expected figure includes it), andListForAgingshows it — the spec says repayment waits for a signature and is silent on a refused one; re-acquitting at the approved figure would need an accrual-only reversal the engine cannot express (A5) — kept by the controller (2026-09-22, contract §8.1): the excess stays owed and visible and the advance stays open; closing it needs a write-off / adjustment document kind, a later plan’s (Notes for later plans). - A33 Ruling: The binder’s
MayRead(ctx, tx, c, documentID, memberID)first checksmember.Can(ctx, tx, memberID, ReadAllPermission)(advances.advance.read_all) and answers true when the document backs any advance; otherwise it answers withCanRead(ctx, tx, c, a, memberID, false)for each advance the document backs, using the clock it is handed — contract §8.3:GET /v1/documents/{id}is now authenticated-only and the data rule decides (uploader, any binder’sMayRead, plus the expenseread_allshortcut), and each plan’s binder honours its ownread_allinsideMayRead, as Plan 8’s does — none. Uploads (POST /v1/documents) need the base’s newdocuments.document.upload, seeded on every filing role; Plan 7 adds no document route. - A34 Ruling: Audit entity kinds
advance(create, update, transition),advance_acquittal(record) andadvance_settlement(reseal); activity rows for advance created, acquittal recorded, and each transition (verbssubmitted,paid,acquitted,returned,transitioned) — Plan 5 R38’s pattern — none.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| File | Task | Responsibility |
|---|---|---|
00130_advances.sql |
4 | advance, advance_acquittal; draft-only header trigger; acquittal-while-received trigger; variance-leg trigger; RLS; DELETE revoked. |
00131_advance_settlement.sql |
4 | advance_settlement (sealed snapshot CHECKs, generated channel_key/account_tail, UPDATE (snapshot) only, reseal-only trigger), advance_idempotency_key (operational). |
New Go packages
Section titled “New Go packages”| Path | Task(s) | Responsibility |
|---|---|---|
internal/platform/advances/ |
2, 3, 5–13, 15 | doc.go (kinds, SourceKinds, constants), decide.go (pure), graphs.go (seed registration), advance.go, visibility.go, cursor.go, submission.go, acquittal.go, binder.go, settlement.go, transition.go, overspend.go (pure), available.go, reseal.go, posting.go, posting_advance.go, posting_variance.go, aging.go, reminders.go, reconcile.go, idempotency.go. |
internal/platform/advances/advancestest/ |
4, 8 | The test world: expensestest.Env (a provisioned MY org whose step 60 also seeded the three advance kinds), walk helpers, control-balance reader. |
internal/modules/advances/ |
14, 15 | Permissions, routes, handlers, boot (posting rules). |
Modified (shared, append-only)
Section titled “Modified (shared, append-only)”| Path | Task | Change |
|---|---|---|
internal/kernel/db/retention_test.go |
4 | Four tables into their lists. |
sqlc.yaml |
4 | money.Rate overrides for advance.fx_rate, advance_acquittal.actual_fx_rate. |
cmd/migrate/reseal.go, docs/DEPLOY_NOTES.md |
9 | Advance settlements join the reseal walk; a Plan 7 section on top. |
constants.yaml (+ generated vocab) |
14 | Four advances.* permissions. |
api/openapi.yaml (+ internal/gen/api) |
14, 15 | /advances/…. |
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go |
14 | Compose the module. |
docs/ROADMAP.md |
16 | Plan 7’s lines. |
Errors are declared in the file that raises them (no shared errors.go in the platform package), so tasks in one wave never edit the same file.
Task 1: Stand on the built base — rebase onto phase1-wave3 and confirm the seam
Section titled “Task 1: Stand on the built base — rebase onto phase1-wave3 and confirm the seam”No code. The base branch was built while this plan was written, and every symbol below was read on it (phase1-wave3 at 1d9ddab). This task makes sure that is still true on the commit you build on. No task marked “waits for Task 1” starts before it is committed.
Files:
-
Modify:
docs/superpowers/plans/2026-09-22-plan-7-advances.md(only if something moved: the affected briefs and the Reconciliation record) -
Step 1: Rebase.
git fetch && git rebase phase1-wave3(or ontomainonce the base has merged there — the user merges; you only rebase this branch). Rungo test ./internal/platform/workflow/... ./internal/platform/documents/... ./internal/platform/posting/... -count=1→ PASS. -
Step 2: Confirm each row of the Reconciliation record still holds (open the file at the line given; a moved line number is fine, a changed name or signature is not):
| # | Symbol used here | Used by task(s) |
|---|---|---|
| 1 | posting.Event.PastPayout; PostsToLedger() true for Rejected/Cancelled when PastAccrual || PastPayout |
10, 11 |
| 2 | workflow_instance.payout_open (migration 00034): set entering a payout, cleared entering rejected/cancelled |
4, 12, 13 |
| 3 | workflow.Instance.PayoutOpen bool |
6, 8, 12 |
| 4 | EdgeSpec.ReversesAccrual — the flag rule 5 now demands past an accrual or a payout |
3 |
| 5 | workflow.CashOnly; rule 3 under it: code accrual_in_cash_only, (a) and (b) kept, (c) skipped |
3 |
| 6 | Rule 1 unchanged for cash_only; workflow.Start still needs a draft state |
3 (Ruling A3) |
| 7 | workflow.RegisterSeededGraph(kind string, build func(ownerSeat uuid.UUID) workflow.Graph) — calls build(uuid.Nil) at registration to check the kind; SeededGraphs = expense_claim, then kind order |
3 |
| 8 | workflowtest.RequireSeededGraphValid(t workflowtest.TB, kind string) |
3 |
| 9 | documents.Binder{Kind(); BoundTo(ctx, tx, documentID); MayRead(ctx, tx, c clock.Clock, documentID, memberID)}, documents.Binding{Kind, RecordID, Reference}, RegisterBinder, UnregisterBinderForTest, BoundElsewhere(ctx, tx, documentID, except Binding), MayRead(ctx, tx, c, documentID, memberID) |
6 |
| 10 | expenses.ErrDocumentBoundElsewhere, *expenses.DocumentBoundElsewhereError{Bindings}; AttachDocument locks claim, then document, then BoundElsewhere |
6 |
| 11 | modules/expenses’ GET /v1/documents/:id: uploader, expenses.claim.read_all, then documents.MayRead |
16 |
| 12 | workflow.Refusal has a Code string field |
8 |
| 13 | ledger.ReverseAll copies source_kind/source_id onto the reversal |
10, 11, 13 |
| 14 | internal/platform/taxrecord (lands on phase1-wave3 after this plan was written; contract §8.1). Expected: taxrecord.Record{DocumentBasis, SupplierName, SupplierTIN, EInvoiceUUID, RelaxationReason *string}; taxrecord.Normalize(r Record) (Record, error) — the save-time shape; taxrecord.Complete(r Record) error — a basis is present; taxrecord.CheckEmployeeNamed(basis *string, allowed bool) error; the basis constants taxrecord.BasisEInvoiceCompany, BasisEInvoiceEmployee, BasisForeignSupplier, BasisRelaxationReceipt; refusals as *taxrecord.FieldError{Field, Rule, Detail} with Plan 5’s rule codes (tin_shape, einvoice_uuid_shape, einvoice_identifiers, relaxation_reason, basis_unknown, employee_named_not_allowed, required) |
4, 6, 8, 15 |
| 15 | Document routes as amended (contract §8.3): POST /v1/documents needs documents.document.upload; GET /v1/documents/{id} authenticated-only, deciding by uploader, documents.MayRead, or expenses.claim.read_all; the bound-elsewhere 409 names only the kind |
6, 15, 16 |
-
Step 3: If anything moved, edit the affected task’s Interfaces and code blocks to the name as built and record it in the appendix; if a symbol is gone, stop and report to the user. Row 14 is expected to differ in names: rewrite every
taxrecord.reference in Tasks 4, 6, 8 and 15 to the package as built (function names, the record type’s field names, the error type and its rule codes), and if the built migration CHECK list in00072differs from Task 4’s mirror, follow00072. If nothing moved, add one line to the appendix: “Confirmed on<sha>; no change.” -
Step 4: Commit.
git add docs/superpowers/plans/2026-09-22-plan-7-advances.mdgit commit -m "Plan 7: confirm the phase1-wave3 seam on the build base
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 2: The acquittal decision table — a pure function, heavily table-tested
Section titled “Task 2: The acquittal decision table — a pure function, heavily table-tested”Needs nothing. Runs before the base lands.
Files:
- Create:
internal/platform/advances/doc.go,internal/platform/advances/decide.go - Test:
internal/platform/advances/decide_test.go
Interfaces:
-
Consumes:
money.Amount(IsPositive,IsNegative,IsZero,Sub,Neg,Cmp,money.Zero,money.MustParsein tests — use whichever parse helperinternal/kernel/moneyexports; Plan 5’s tests use it). -
Produces:
package advancesconst (Kind = "advance"KindTopup = "advance_topup"KindRefund = "advance_refund"ControlKind = "employee_advances"BinderKind = "advance"ReadAllPermission = "advances.advance.read_all"EntityAdvance = "advance"EntityAcquittal = "advance_acquittal"EntitySettlement = "advance_settlement")// SourceKinds is every journal_entry.source_kind an advance posts under// (contract §2.1). Plan 9's aged-advances query sums over this set.var SourceKinds = []string{Kind, KindTopup, KindRefund}type Outcome stringconst (OutcomeSettled Outcome = "settled" // actual = approved: terminal at acquittedOutcomeTopup Outcome = "topup" // actual > approved: advance_topup pays the excessOutcomeRefund Outcome = "refund" // 0 < actual < approved: advance_refund brings the rest backOutcomeReturned Outcome = "returned" // actual = 0: received -> returned reverses the payout (Ruling A4))type AcquittalFacts struct {Approved money.Amount // the advance's base_amountActualAmount money.Amount // as entered, in the receipt's currencyActualBase money.Amount // ActualAmount at the receipt date's rateReceiptDate *time.TimeReceiptDocument *uuid.UUIDCategoryID *uuid.UUIDReason stringToday time.Time // the business date; the function reads no clock}type AcquittalDecision struct {Outcome OutcomeVariance money.Amount // ActualBase - Approved, signedLegAmount money.Amount // |Variance| for topup/refund, zero otherwise}// AcquittalError names the one field and rule that failed.type AcquittalError struct{ Field, Rule, Detail string }func (e *AcquittalError) Error() string // "field=<Field> rule=<Rule>: <Detail>"func DecideAcquittal(f AcquittalFacts) (AcquittalDecision, error) -
Step 1: Write the failing tests —
decide_test.go, packageadvances(internal; no database):
func TestDecideAcquittal(t *testing.T) { today := time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC) doc, cat := uuid.New(), uuid.New() d := func(y, m, day int) *time.Time { v := time.Date(y, time.Month(m), day, 0, 0, 0, 0, time.UTC); return &v } type want struct { outcome advances.Outcome variance, leg string field, rule string // set when an *AcquittalError is expected } cases := []struct { name string f advances.AcquittalFacts want want }{ // PRESENCE: the four accepted rows of spec 7.3. {"exact", facts("1000", "1000", "1000", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeSettled, variance: "0", leg: "0"}}, {"overspend", facts("1000", "1200", "1200", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeTopup, variance: "200", leg: "200"}}, {"underspend", facts("1000", "700", "700", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeRefund, variance: "-300", leg: "300"}}, {"nothing spent", facts("1000", "0", "0", nil, nil, nil, "Trip cancelled"), want{outcome: advances.OutcomeReturned, variance: "-1000", leg: "0"}}, {"one sen over", facts("1000", "1000.0100", "1000.0100", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeTopup, variance: "0.0100", leg: "0.0100"}}, {"receipt dated today", facts("1000", "10", "10", d(2026, 9, 21), &doc, &cat, ""), want{outcome: advances.OutcomeRefund, variance: "-990", leg: "990"}}, {"foreign receipt converted", facts("1000", "300", "1260", d(2026, 9, 19), &doc, &cat, ""), want{outcome: advances.OutcomeTopup, variance: "260", leg: "260"}}, {"reason allowed with a receipt", facts("1000", "1200", "1200", d(2026, 9, 20), &doc, &cat, "Fare rose"), want{outcome: advances.OutcomeTopup, variance: "200", leg: "200"}}, // ABSENCE: every refusal, one field each. {"negative actual", facts("1000", "-1", "-1", d(2026, 9, 20), &doc, &cat, ""), want{field: "actual_amount", rule: "negative"}}, {"negative base", facts("1000", "1", "-1", d(2026, 9, 20), &doc, &cat, ""), want{field: "actual_amount", rule: "negative"}}, {"zero base for a real amount", facts("1000", "1", "0", d(2026, 9, 20), &doc, &cat, ""), want{field: "actual_base", rule: "mismatch"}}, {"spent without a receipt", facts("1000", "900", "900", d(2026, 9, 20), nil, &cat, ""), want{field: "receipt_document_id", rule: "required"}}, {"spent without a date", facts("1000", "900", "900", nil, &doc, &cat, ""), want{field: "receipt_date", rule: "required"}}, {"receipt from the future", facts("1000", "900", "900", d(2026, 9, 22), &doc, &cat, ""), want{field: "receipt_date", rule: "future"}}, {"spent without a category", facts("1000", "900", "900", d(2026, 9, 20), &doc, nil, ""), want{field: "category_id", rule: "required"}}, {"nothing spent, no reason", facts("1000", "0", "0", nil, nil, nil, " "), want{field: "reason", rule: "required"}}, {"nothing spent, yet a receipt", facts("1000", "0", "0", nil, &doc, nil, "Trip cancelled"), want{field: "receipt_document_id", rule: "none_exists"}}, {"nothing spent, yet a date", facts("1000", "0", "0", d(2026, 9, 20), nil, nil, "Trip cancelled"), want{field: "receipt_date", rule: "none_exists"}}, {"nothing spent, yet a category", facts("1000", "0", "0", nil, nil, &cat, "Trip cancelled"), want{field: "category_id", rule: "none_exists"}}, {"no approved amount", facts("0", "10", "10", d(2026, 9, 20), &doc, &cat, ""), want{field: "approved", rule: "invalid"}}, } // facts(approved, actualAmount, actualBase, date, doc, cat, reason) builds // AcquittalFacts with Today = today (a helper in this file). // For each case: DecideAcquittal; either require.NoError and compare // Outcome, Variance.Cmp(want) == 0 and LegAmount.Cmp(want) == 0, or // errors.As(*advances.AcquittalError) with Field and Rule equal and the // decision zero-valued.}
func TestVarianceSignDecidesTheOutcomeAcrossAGrid(t *testing.T) { // Approved 1000. For actual in {0.0001, 1, 999.9999, 1000, 1000.0001, // 5000}: Outcome is Refund, Refund, Refund, Settled, Topup, Topup; and // always Variance == actual - 1000 and LegAmount == |Variance| (zero when // settled). Actual 0 is Returned with Variance -1000 and LegAmount 0.}
func TestAcquittalErrorNamesFieldAndRule(t *testing.T) { // (&AcquittalError{Field: "receipt_date", Rule: "future", Detail: "…"}).Error() // == "field=receipt_date rule=future: …" — the shape Plan 5 R43 fixed.}
func TestDecideTouchesNoDatabase(t *testing.T) { // go/parser over decide.go: its imports are a subset of // {"errors", "fmt", "strings", "time", "github.com/google/uuid", // "github.com/mcom/erp/internal/kernel/money"}. // PRESENCE: the file parsed and listed at least three imports.}-
Step 2: Run and watch fail.
go test ./internal/platform/advances/ -run 'Decide|Variance|AcquittalError' -count=1→ FAIL (undefined). -
Step 3: Implement.
doc.gocarries the package comment (what an advance is, §7.1’s rule, the three kinds and why they are three — contract §2.1 — and the constants above).decide.go:
// DecideAcquittal is spec 7.3's decision table, kept database-free (korre's// validateAcquittal, adopted). Checks run in a fixed order and the first// failure is returned, so a caller can show one field at a time.func DecideAcquittal(f AcquittalFacts) (AcquittalDecision, error) { fail := func(field, rule, detail string) (AcquittalDecision, error) { return AcquittalDecision{}, &AcquittalError{Field: field, Rule: rule, Detail: detail} } if !f.Approved.IsPositive() { return fail("approved", "invalid", "an advance is approved at a positive amount") } if f.ActualAmount.IsNegative() || f.ActualBase.IsNegative() { return fail("actual_amount", "negative", "an amount spent cannot be negative") } if f.ActualAmount.IsZero() != f.ActualBase.IsZero() { return fail("actual_base", "mismatch", "the converted amount is zero exactly when the amount is") } if f.ActualAmount.IsPositive() { switch { case f.ReceiptDocument == nil: return fail("receipt_document_id", "required", "money was spent, so a receipt is required") case f.ReceiptDate == nil: return fail("receipt_date", "required", "the date on the receipt is required") case f.ReceiptDate.After(f.Today): return fail("receipt_date", "future", "a receipt cannot be dated after today") case f.CategoryID == nil: return fail("category_id", "required", "the category says which expense account the receipt is charged to") } v := f.ActualBase.Sub(f.Approved) switch v.Cmp(money.Zero()) { case 1: return AcquittalDecision{Outcome: OutcomeTopup, Variance: v, LegAmount: v}, nil case -1: return AcquittalDecision{Outcome: OutcomeRefund, Variance: v, LegAmount: v.Neg()}, nil } return AcquittalDecision{Outcome: OutcomeSettled, Variance: v, LegAmount: money.Zero()}, nil } switch { case strings.TrimSpace(f.Reason) == "": return fail("reason", "required", "nothing was spent, so say why the whole advance is coming back") case f.ReceiptDocument != nil: return fail("receipt_document_id", "none_exists", "nothing was spent, so there is no receipt to attach") case f.ReceiptDate != nil: return fail("receipt_date", "none_exists", "nothing was spent, so there is no receipt date") case f.CategoryID != nil: return fail("category_id", "none_exists", "nothing was spent, so nothing is charged to a category") } return AcquittalDecision{Outcome: OutcomeReturned, Variance: f.Approved.Neg(), LegAmount: money.Zero()}, nil}-
Step 4: Run. PASS. Sabotage: change
ReceiptDate.After(f.Today)to!ReceiptDate.Before(f.Today)→ “receipt dated today” goes red; quote; restore. -
Step 5: Commit —
git add internal/platform/advances && git commit -m "Decide advance acquittals with a pure, table-tested function".
Task 3: The three seeded graphs — registered, validated, pinned
Section titled “Task 3: The three seeded graphs — registered, validated, pinned”Waits for Task 1. Needs Task 2 (doc.go).
Files:
- Create:
internal/platform/advances/graphs.go - Test:
internal/platform/advances/graphs_test.go
Interfaces:
-
Consumes (as built on
phase1-wave3):workflow.Graph,StateSpec,EdgeSpec,GrantSpec,Labels, gates, grantee kinds,workflow.PayoutFirst,workflow.CashOnly,workflow.RegisterSeededGraph,workflow.SeededGraphs,workflow.ValidateGraph,workflowtest.RequireSeededGraphValid;postingsemantics. -
Produces:
func AdvanceGraph(ownerSeat uuid.UUID) workflow.Graph // payout_firstfunc TopupGraph(ownerSeat uuid.UUID) workflow.Graph // cash_onlyfunc RefundGraph(ownerSeat uuid.UUID) workflow.Graph // cash_only// init(): RegisterSeededGraph(Kind, AdvanceGraph), (KindTopup, TopupGraph), (KindRefund, RefundGraph).State keys (data, but pinned by tests because the seeds are the contract): advance
draft(draft) ·pending_review,reviewed,approved(pending) ·paid_out(payout) ·received(acknowledged) ·acquitted(accrual, controlemployee_advances, terminal) ·returned(cancelled, terminal) ·rejected(rejected) ·cancelled(cancelled, terminal) ·disputed(dispute) ·acquittal_disputed(dispute) ·acquittal_confirmed(acknowledged, terminal). advance_topupdraft·topup_pending,topup_approved(pending) ·topup_paid(payout) ·topup_received(acknowledged, terminal) ·rejected·disputed. advance_refunddraft·refund_pending(pending) ·refund_confirmed(payout, terminal) ·refund_settled(acknowledged, terminal) ·rejected·disputed. -
Step 1: Write the failing tests —
graphs_test.go:
func TestEachAdvanceGraphIsValid(t *testing.T) { for _, k := range advances.SourceKinds { workflowtest.RequireSeededGraphValid(t, k) }}func TestTheGraphsAreRegisteredForSeeding(t *testing.T) { // kinds of workflow.SeededGraphs(uuid.New()) == ["expense_claim", // "advance", "advance_refund", "advance_topup"] (expense_claim first, // then kind order, contract §1.3) — or a superset when another plan's // package is linked; assert the advance kinds appear in that order after // expense_claim.}func TestTheAdvanceGraphIsPayoutFirst(t *testing.T) { // PostingOrder == workflow.PayoutFirst; the state table above exactly // (key, semantic, is_terminal, control_kind), and exactly one accrual // (acquitted, "employee_advances") and one payout (paid_out).}func TestTheVarianceGraphsAreCashOnly(t *testing.T) { // TopupGraph and RefundGraph: PostingOrder == workflow.CashOnly, no // accrual state, exactly one payout state (topup_paid / refund_confirmed), // exactly one draft (Ruling A3); the state tables above exactly.}func TestEveryPostPayoutStateCanBeDisputed(t *testing.T) { // For each graph: every non-dispute, non-reversing state reachable from // its payout state without entering a reversing state has an edge into a // dispute-semantic state (spec 7.2). PRESENCE: the walk visited >= 3 // states in the advance graph and >= 2 in each leg graph.}func TestEveryExitToAReversingStateAfterPayoutReverses(t *testing.T) { // For each graph, every edge into a rejected/cancelled state from a state // at or past the payout carries ReversesAccrual (rule 5 as amended, §1.1): // advance disputed->rejected, received->returned; topup disputed->rejected; // refund disputed->rejected. And no edge from a pre-payout state carries // it (pending_review->rejected, draft->cancelled, topup_pending->rejected).}func TestTheGrantsAreTheSeededAuthorities(t *testing.T) { // Ruling A7's table: reviewed<-superior, approved<-unit_head, // paid_out/acquitted/returned/disputed/acquittal_disputed<-position // ownerSeat; topup_approved<-unit_head, topup_paid/disputed<-ownerSeat; // refund_confirmed/disputed<-ownerSeat. Every MinAmount nil.}func TestTheContractsLiteralGraphsWouldBeRefused(t *testing.T) { // Documents Rulings A3, A5, A6 as executable facts, each with PRESENCE // first (the seeded graph validates clean): // (a) TopupGraph with its draft state and draft edges removed and // topup_pending as the entry -> ValidateGraph has code "draft_count"; // (b) AdvanceGraph plus edge acquittal_disputed -> acquitted (source, // dismiss) -> code "accrual_on_cycle"; // (c) RefundGraph with disputed -> refund_confirmed replacing // disputed -> refund_settled -> code "payout_on_cycle".}-
Step 2: Run and watch fail.
go test ./internal/platform/advances/ -run 'Graph|Seed|Dispute|Revers|Grant|Contract' -count=1→ FAIL. -
Step 3: Implement
graphs.go:
func init() { workflow.RegisterSeededGraph(Kind, AdvanceGraph) workflow.RegisterSeededGraph(KindTopup, TopupGraph) workflow.RegisterSeededGraph(KindRefund, RefundGraph)}
func ls(en, zh string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh} }
func st(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec { s := workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l} if sem == posting.Accrual { s.ControlKind = ControlKind // Employee Advances for an advance (spec 5.5) } return s}
func ed(order int, from, to string, gate workflow.Gate, action string, l workflow.Labels) workflow.EdgeSpec { return workflow.EdgeSpec{From: from, To: to, Gate: gate, ActionKey: action, DisplayOrder: order, Labels: l}}
// why marks an edge that needs a reason; reverses sets ReversesAccrual, the// flag rule 5 demands past an accrual or a payout (wave-3 §1.1).func why(e workflow.EdgeSpec, reverses bool) workflow.EdgeSpec { e.RequiresReason, e.ReversesAccrual = true, reverses return e}
func seat(state string, ownerSeat uuid.UUID) workflow.GrantSpec { return workflow.GrantSpec{StateKey: state, GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}}
// AdvanceGraph is spec 7.2's advance up to acquittal (contract §2.1), with// Rulings A4 (returned) and A5 (the acquittal dispute)://// draft → pending_review → reviewed → approved → paid_out → received → acquitted// │ └──────────┴──────────┴─→ rejected → draft │ ├─→ acquittal_disputed ⇄ acquittal_confirmed// └─→ cancelled paid_out, received ─→ disputed ─→ received | rejected (reverses)// received ─→ returned (reverses the payout)func AdvanceGraph(ownerSeat uuid.UUID) workflow.Graph { reject, dispute := ls("Reject", "拒绝"), ls("Raise dispute", "提出争议") return workflow.Graph{ Kind: Kind, PostingOrder: workflow.PayoutFirst, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿")), st(2, "pending_review", posting.Pending, false, ls("Pending review", "待审核")), st(3, "reviewed", posting.Pending, false, ls("Reviewed", "已审核")), st(4, "approved", posting.Pending, false, ls("Approved", "已批准")), st(5, "paid_out", posting.Payout, false, ls("Paid out", "已支付")), st(6, "received", posting.Acknowledged, false, ls("Received", "已收款")), st(7, "acquitted", posting.Accrual, true, ls("Acquitted", "已核销")), st(8, "returned", posting.Cancelled, true, ls("Returned in full", "已全额退回")), st(9, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝")), st(10, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回")), st(11, "disputed", posting.Dispute, false, ls("Disputed", "有争议")), st(12, "acquittal_disputed", posting.Dispute, false, ls("Acquittal disputed", "核销有争议")), st(13, "acquittal_confirmed", posting.Acknowledged, true, ls("Acquittal confirmed", "核销已确认")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_review", workflow.GateReporter, "submit", ls("Submit", "提交")), ed(2, "pending_review", "reviewed", workflow.GateTarget, "review", ls("Mark reviewed", "审核通过")), ed(3, "reviewed", "approved", workflow.GateTarget, "approve", ls("Approve", "批准")), ed(4, "approved", "paid_out", workflow.GateTarget, "pay", ls("Mark paid", "标记已支付")), ed(5, "paid_out", "received", workflow.GateReporter, "acknowledge", ls("Confirm receipt", "确认收款")), ed(6, "received", "acquitted", workflow.GateTarget, "acquit", ls("Accept acquittal", "接受核销")), why(ed(7, "received", "returned", workflow.GateTarget, "confirm_return", ls("Confirm full return", "确认全额退回")), true), why(ed(8, "pending_review", "rejected", workflow.GateAdvance, "reject", reject), false), why(ed(9, "reviewed", "rejected", workflow.GateAdvance, "reject", reject), false), why(ed(10, "approved", "rejected", workflow.GateAdvance, "reject", reject), false), ed(11, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改")), why(ed(12, "draft", "cancelled", workflow.GateReporter, "withdraw", ls("Withdraw", "撤回")), false), why(ed(13, "paid_out", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(14, "received", "disputed", workflow.GateReporter, "dispute", dispute), false), ed(15, "disputed", "received", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), why(ed(16, "disputed", "rejected", workflow.GateSource, "uphold", ls("Uphold dispute", "支持争议")), true), why(ed(17, "acquitted", "acquittal_disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(18, "acquittal_confirmed", "acquittal_disputed", workflow.GateReporter, "dispute", dispute), false), ed(19, "acquittal_disputed", "acquittal_confirmed", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), }, Grants: []workflow.GrantSpec{ {StateKey: "reviewed", GranteeKind: workflow.GranteeSuperior}, {StateKey: "approved", GranteeKind: workflow.GranteeUnitHead}, seat("paid_out", ownerSeat), seat("acquitted", ownerSeat), seat("returned", ownerSeat), seat("disputed", ownerSeat), seat("acquittal_disputed", ownerSeat), }, }.Normalized()}
// TopupGraph pays an overspend once an approver has signed off on the// excess (spec 7.3; contract §2.1 plus Ruling A3's draft).func TopupGraph(ownerSeat uuid.UUID) workflow.Graph { reject, dispute := ls("Reject", "拒绝"), ls("Raise dispute", "提出争议") return workflow.Graph{ Kind: KindTopup, PostingOrder: workflow.CashOnly, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿")), st(2, "topup_pending", posting.Pending, false, ls("Excess awaiting approval", "超支待批准")), st(3, "topup_approved", posting.Pending, false, ls("Excess approved", "超支已批准")), st(4, "topup_paid", posting.Payout, false, ls("Top-up paid", "补付已支付")), st(5, "topup_received", posting.Acknowledged, true, ls("Top-up received", "补付已收款")), st(6, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝")), st(7, "disputed", posting.Dispute, false, ls("Disputed", "有争议")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "topup_pending", workflow.GateReporter, "submit", ls("Request top-up", "申请补付")), ed(2, "topup_pending", "topup_approved", workflow.GateTarget, "approve_excess", ls("Approve excess", "批准超支")), ed(3, "topup_approved", "topup_paid", workflow.GateTarget, "pay", ls("Mark paid", "标记已支付")), ed(4, "topup_paid", "topup_received", workflow.GateReporter, "acknowledge", ls("Confirm receipt", "确认收款")), why(ed(5, "topup_pending", "rejected", workflow.GateAdvance, "reject", reject), false), why(ed(6, "topup_approved", "rejected", workflow.GateAdvance, "reject", reject), false), ed(7, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改")), why(ed(8, "topup_paid", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(9, "topup_received", "disputed", workflow.GateReporter, "dispute", dispute), false), ed(10, "disputed", "topup_received", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), why(ed(11, "disputed", "rejected", workflow.GateSource, "uphold", ls("Uphold dispute", "支持争议")), true), }, Grants: []workflow.GrantSpec{ {StateKey: "topup_approved", GranteeKind: workflow.GranteeUnitHead}, seat("topup_paid", ownerSeat), seat("disputed", ownerSeat), }, }.Normalized()}
// RefundGraph brings an underspend back (spec 7.4; contract §2.1 plus// Rulings A3 and A6).func RefundGraph(ownerSeat uuid.UUID) workflow.Graph { dispute := ls("Raise dispute", "提出争议") return workflow.Graph{ Kind: KindRefund, PostingOrder: workflow.CashOnly, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿")), st(2, "refund_pending", posting.Pending, false, ls("Refund declared", "退款已申报")), st(3, "refund_confirmed", posting.Payout, true, ls("Refund confirmed", "退款已确认")), st(4, "refund_settled", posting.Acknowledged, true, ls("Refund settled", "退款已结清")), st(5, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝")), st(6, "disputed", posting.Dispute, false, ls("Disputed", "有争议")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "refund_pending", workflow.GateReporter, "declare", ls("Declare refund", "申报退款")), ed(2, "refund_pending", "refund_confirmed", workflow.GateTarget, "confirm", ls("Confirm refund received", "确认已收退款")), why(ed(3, "refund_confirmed", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(4, "refund_settled", "disputed", workflow.GateReporter, "dispute", dispute), false), ed(5, "disputed", "refund_settled", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), why(ed(6, "disputed", "rejected", workflow.GateSource, "uphold", ls("Uphold dispute", "支持争议")), true), ed(7, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改")), }, Grants: []workflow.GrantSpec{seat("refund_confirmed", ownerSeat), seat("disputed", ownerSeat)}, }.Normalized()}-
Step 4: Run.
go test ./internal/platform/advances/ ./internal/platform/workflow/... -count=1→ PASS (the base’s drift test overSeededGraphsnow covers these three as well). -
Step 5: Sabotage. Clear the reversal flag on the advance’s
disputed → rejected→TestEachAdvanceGraphIsValidfails with rule 5reversal_missing(contract §1.1); quote; restore. Then drop theacquittal_disputedstate and its edges →TestEveryPostPayoutStateCanBeDisputedfails namingacquitted; quote; restore. -
Step 6: Commit —
git commit -m "Seed the advance, top-up and refund workflows through the graph registry".
Task 4: Migrations 00130–00131 — the advance, its acquittal and settlements; the test world
Section titled “Task 4: Migrations 00130–00131 — the advance, its acquittal and settlements; the test world”Waits for Task 1. Needs Task 3 (the fixture’s step 60 must seed the three kinds).
Files:
- Create:
db/migrations/00130_advances.sql,db/migrations/00131_advance_settlement.sql,internal/platform/advances/advancestest/env.go - Modify:
internal/kernel/db/retention_test.go,sqlc.yaml(append overridesadvance.fx_rate,advance_acquittal.actual_fx_rate→money.Rate), thenmake generate - Test:
internal/platform/advances/schema_test.go,internal/platform/advances/advancestest/env_test.go
Interfaces:
-
Consumes: Plan 3’s
workflow_instance,workflow_state,workflow.Start,workflow.Transition,workflow.Latest; contract §1.1’sworkflow_instance.payout_open(migration00034); Plan 5’sdocument,expense_category; Plan 4’saccount;expensestest.New,expensestest.Env(all of Plan 5’s helpers:Tx,TxErr,Member,Document,Channel,CreditAccount,SeedChannel,PayoutProfile,Count,UsePoster,Categories,Owner,Staff,OwnerSeat,Clock,H). -
Produces the tables every later task uses verbatim (the SQL below), and:
package advancestest// Env is Plan 5's provisioned MY world. Because this package imports// internal/platform/advances, the three advance graphs are registered// before provisioning runs, so step 60 publishes them at v1 too.type Env struct{ *expensestest.Env }func New(t *testing.T) *Envfunc (e *Env) Another(t *testing.T) *Env// NopPoster registers a poster that posts nothing, for tests of the// advance side alone (Tasks 5–9); UseAdvancePoster (Task 10) replaces it.func (e *Env) NopPoster(t *testing.T)// Control is the Employee Advances control account (MY 1550).func (e *Env) Control(t *testing.T) uuid.UUID// ControlBalance is the advance's signed base balance on every account// of control kind employee_advances, over entries whose source_kind is// in advances.SourceKinds and source_id is the advance (read as admin).func (e *Env) ControlBalance(t *testing.T, advanceID uuid.UUID) money.Amount// StartInstance starts a bare instance of kind for source, in its own// transaction — for schema tests that need an instance before the// header API exists (Task 5).func (e *Env) StartInstance(t *testing.T, kind string, source uuid.UUID, reporter uuid.UUID, amount string) workflow.Instance -
Step 1: Write migration 00130.
-- +goose Up
-- ADVANCES (spec 7, contract §2.1). The header. Its workflow state lives-- on the advance instance; the two variance legs are separate instances of-- their own kinds, all with source_id = this row's id.CREATE TABLE advance ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, claimant_member_id UUID NOT NULL, holder_party_id UUID NOT NULL, instance_id UUID NOT NULL, topup_instance_id UUID, refund_instance_id UUID, purpose TEXT NOT NULL CONSTRAINT advance_purpose_present CHECK (btrim(purpose) <> '' AND length(purpose) <= 500), currency TEXT NOT NULL CONSTRAINT advance_currency_code CHECK (currency ~ '^[A-Z]{3}$'), amount NUMERIC(19,4) NOT NULL CONSTRAINT advance_amount_positive CHECK (amount > 0), fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT advance_fx_rate_positive CHECK (fx_rate > 0), fx_rate_source TEXT NOT NULL CONSTRAINT advance_fx_rate_source_known CHECK (fx_rate_source IN ('identity','reference','override')), base_amount NUMERIC(19,4) NOT NULL CONSTRAINT advance_base_positive CHECK (base_amount > 0), due_on DATE NOT NULL, -- The claimant's preferred channel (Ruling A18). Plan 6's table is reached -- only through payout.GetChannel, so there is no FK here. payout_channel_id UUID, revision INT NOT NULL DEFAULT 1 CONSTRAINT advance_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT advance_tenant_id UNIQUE (org_id, id), CONSTRAINT advance_reference_once UNIQUE (org_id, reference), CONSTRAINT advance_instance_once UNIQUE (org_id, instance_id), CONSTRAINT advance_topup_instance_once UNIQUE (org_id, topup_instance_id), CONSTRAINT advance_refund_instance_once UNIQUE (org_id, refund_instance_id), -- Spec 7.3: the variance fans out one way, never two. CONSTRAINT advance_one_variance_leg CHECK (topup_instance_id IS NULL OR refund_instance_id IS NULL), CONSTRAINT advance_purpose_language CHECK (purpose !~ <cjk>), CONSTRAINT advance_claimant_fk FOREIGN KEY (org_id, claimant_member_id) REFERENCES organization_member (org_id, id), CONSTRAINT advance_holder_party_fk FOREIGN KEY (org_id, holder_party_id) REFERENCES party (org_id, id), CONSTRAINT advance_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT advance_topup_instance_fk FOREIGN KEY (org_id, topup_instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT advance_refund_instance_fk FOREIGN KEY (org_id, refund_instance_id) REFERENCES workflow_instance (org_id, id));CREATE INDEX advance_by_claimant ON advance (org_id, claimant_member_id, created_at DESC, id DESC);CREATE INDEX advance_by_created ON advance (org_id, created_at DESC, id DESC);CREATE INDEX advance_by_due ON advance (org_id, due_on);
-- ACQUITTALS (spec 7.3). One per advance: recorded by the claimant while the-- advance sits at `received`, decided (acquitted_at) by the verifier, then-- frozen. The CHECKs are the decision table's database mirror.CREATE TABLE advance_acquittal ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), advance_id UUID NOT NULL, actual_currency TEXT NOT NULL CONSTRAINT advance_acquittal_currency_code CHECK (actual_currency ~ '^[A-Z]{3}$'), actual_amount NUMERIC(19,4) NOT NULL, actual_fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT advance_acquittal_fx_rate_positive CHECK (actual_fx_rate > 0), actual_fx_rate_source TEXT NOT NULL CONSTRAINT advance_acquittal_fx_rate_source_known CHECK (actual_fx_rate_source IN ('identity','reference','override')), actual_base NUMERIC(19,4) NOT NULL, receipt_date DATE, receipt_document_id UUID, category_id UUID, reason TEXT, -- The §6.7 tax record (Ruling A30), with a claim receipt line's rules. document_basis TEXT, supplier_name TEXT, supplier_tin TEXT, einvoice_uuid TEXT, relaxation_reason TEXT, expense_account_id UUID, recorded_by UUID NOT NULL, acquitted_at TIMESTAMPTZ, acquitted_by UUID, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT advance_acquittal_tenant_id UNIQUE (org_id, id), CONSTRAINT advance_acquittal_once UNIQUE (org_id, advance_id), -- Spec 7.3 row 3: actual < 0 is refused; the database mirrors it. CONSTRAINT advance_acquittal_actual_nonnegative CHECK (actual_amount >= 0), CONSTRAINT advance_acquittal_base_nonnegative CHECK (actual_base >= 0), CONSTRAINT advance_acquittal_base_sign CHECK (sign(actual_base) = sign(actual_amount)), -- Spec 7.3 row 1: actual > 0 needs the receipt, a date on it, and the -- category that says which expense account it is charged to. CONSTRAINT advance_acquittal_receipt_required CHECK (actual_amount = 0 OR (receipt_date IS NOT NULL AND receipt_document_id IS NOT NULL AND category_id IS NOT NULL)), -- Spec 7.3 row 2: actual = 0 needs a reason, and there is no receipt. CONSTRAINT advance_acquittal_reason_required CHECK (actual_amount > 0 OR (btrim(coalesce(reason, '')) <> '' AND receipt_date IS NULL AND receipt_document_id IS NULL AND category_id IS NULL)), CONSTRAINT advance_acquittal_nothing_at_identity CHECK (actual_amount > 0 OR (actual_fx_rate = 1 AND actual_fx_rate_source = 'identity')), CONSTRAINT advance_acquittal_decided_pair CHECK ((acquitted_at IS NULL) = (acquitted_by IS NULL)), -- The account is frozen when, and only when, the acquittal is decided and -- something was spent (Ruling A17). CONSTRAINT advance_acquittal_account_frozen CHECK ( (acquitted_at IS NULL AND expense_account_id IS NULL) OR (acquitted_at IS NOT NULL AND (actual_amount > 0) = (expense_account_id IS NOT NULL))), CONSTRAINT advance_acquittal_reason_language CHECK (coalesce(reason, '') !~ <cjk>), -- The tax record, mirroring 00072_expense_claims.sql one for one: -- mirrors expense_claim_line_basis_known. CONSTRAINT advance_acquittal_basis_known CHECK (document_basis IS NULL OR document_basis IN ('einvoice_company','einvoice_employee','foreign_supplier','relaxation_receipt')), -- mirrors expense_claim_line_einvoice_identifiers. CONSTRAINT advance_acquittal_einvoice_identifiers CHECK (document_basis IS NULL OR document_basis NOT IN ('einvoice_company','einvoice_employee') OR (supplier_tin IS NOT NULL AND einvoice_uuid IS NOT NULL)), -- mirrors expense_claim_line_relaxation_reason. CONSTRAINT advance_acquittal_relaxation_reason CHECK (document_basis IS DISTINCT FROM 'relaxation_receipt' OR btrim(coalesce(relaxation_reason, '')) <> ''), -- mirrors expense_claim_line_tin_shape. CONSTRAINT advance_acquittal_tin_shape CHECK (supplier_tin IS NULL OR supplier_tin ~ '^[A-Z]{1,2}[0-9]{8,13}$'), -- mirrors expense_claim_line_einvoice_uuid_shape. CONSTRAINT advance_acquittal_einvoice_uuid_shape CHECK (einvoice_uuid IS NULL OR einvoice_uuid ~ '^[A-Z0-9]{10,64}$'), -- mirrors expense_claim_line_text_language for the tax record's text. CONSTRAINT advance_acquittal_tax_text_language CHECK (coalesce(supplier_name, '') !~ <cjk> AND coalesce(relaxation_reason, '') !~ <cjk>), -- Spec 7.3 row 2 again: nothing spent, no receipt, so no tax record. CONSTRAINT advance_acquittal_nothing_has_no_tax_record CHECK (actual_amount > 0 OR (document_basis IS NULL AND supplier_name IS NULL AND supplier_tin IS NULL AND einvoice_uuid IS NULL AND relaxation_reason IS NULL)), -- Plan 5 R14's completeness half, at the decision: a recognised expense -- names the basis of its evidence. CONSTRAINT advance_acquittal_basis_when_decided CHECK (acquitted_at IS NULL OR actual_amount = 0 OR document_basis IS NOT NULL), CONSTRAINT advance_acquittal_advance_fk FOREIGN KEY (org_id, advance_id) REFERENCES advance (org_id, id), CONSTRAINT advance_acquittal_document_fk FOREIGN KEY (org_id, receipt_document_id) REFERENCES document (org_id, id), CONSTRAINT advance_acquittal_category_fk FOREIGN KEY (org_id, category_id) REFERENCES expense_category (org_id, id), CONSTRAINT advance_acquittal_account_fk FOREIGN KEY (org_id, expense_account_id) REFERENCES account (org_id, id), CONSTRAINT advance_acquittal_recorded_by_fk FOREIGN KEY (org_id, recorded_by) REFERENCES organization_member (org_id, id), CONSTRAINT advance_acquittal_acquitted_by_fk FOREIGN KEY (org_id, acquitted_by) REFERENCES organization_member (org_id, id));-- This consumer's own one-record rule (contract §1.4, Ruling A15).CREATE UNIQUE INDEX advance_acquittal_receipt_once ON advance_acquittal (org_id, receipt_document_id) WHERE receipt_document_id IS NOT NULL;
-- (ENABLE + FORCE ROW LEVEL SECURITY and CREATE POLICY org_isolation with-- USING and WITH CHECK on current_setting('app.current_org', true)::uuid for-- both tables — write each out, as in 00072.)
REVOKE DELETE ON advance, advance_acquittal FROM erp_app;
-- The semantic of the advance's current state, and its two open flags.CREATE FUNCTION advance_state(p_org UUID, p_advance UUID, OUT semantic TEXT, OUT payout_open BOOLEAN, OUT accrual_open BOOLEAN) LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT s.semantic, i.payout_open, i.accrual_open FROM public.advance a JOIN public.workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id JOIN public.workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE a.org_id = p_org AND a.id = p_advance$$;
-- DRAFT-ONLY HEADER EDITS (Plan 5 R16's reasoning, Ruling A12).CREATE FUNCTION advance_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF (SELECT semantic FROM public.advance_state(NEW.org_id, NEW.id)) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'advance % is not a draft: its purpose, amount, due date and channel cannot change', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_is_draft'; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_draft_guard BEFORE UPDATE OF purpose, currency, amount, fx_rate, fx_rate_source, base_amount, due_on, payout_channel_id ON advance FOR EACH ROW EXECUTE FUNCTION advance_draft_guard();
-- THE VARIANCE LEG MATCHES THE ACQUITTAL (Ruling A9). A leg is set once, by-- the acquittal transaction, to an instance of the right kind for this-- advance, and only when the decided acquittal's sign calls for it.CREATE FUNCTION advance_variance_leg_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_actual NUMERIC; v_decided TIMESTAMPTZ; v_kind TEXT; v_source UUID;BEGIN IF TG_OP = 'INSERT' THEN IF NEW.topup_instance_id IS NOT NULL OR NEW.refund_instance_id IS NOT NULL THEN RAISE EXCEPTION 'advance %: a variance leg is set by its acquittal, never at creation', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_matches'; END IF; RETURN NEW; END IF; IF (OLD.topup_instance_id IS NOT NULL AND NEW.topup_instance_id IS DISTINCT FROM OLD.topup_instance_id) OR (OLD.refund_instance_id IS NOT NULL AND NEW.refund_instance_id IS DISTINCT FROM OLD.refund_instance_id) THEN RAISE EXCEPTION 'advance %: a variance leg, once set, never changes', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_fixed'; END IF; SELECT q.actual_base, q.acquitted_at INTO v_actual, v_decided FROM public.advance_acquittal q WHERE q.org_id = NEW.org_id AND q.advance_id = NEW.id; IF NEW.topup_instance_id IS NOT NULL AND NEW.topup_instance_id IS DISTINCT FROM OLD.topup_instance_id THEN SELECT i.kind, i.source_id INTO v_kind, v_source FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = NEW.topup_instance_id; IF v_decided IS NULL OR v_actual <= NEW.base_amount OR v_kind <> 'advance_topup' OR v_source <> NEW.id THEN RAISE EXCEPTION 'advance %: a top-up needs a decided overspend and an advance_topup instance of this advance', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_matches'; END IF; END IF; IF NEW.refund_instance_id IS NOT NULL AND NEW.refund_instance_id IS DISTINCT FROM OLD.refund_instance_id THEN SELECT i.kind, i.source_id INTO v_kind, v_source FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = NEW.refund_instance_id; IF v_decided IS NULL OR v_actual <= 0 OR v_actual >= NEW.base_amount OR v_kind <> 'advance_refund' OR v_source <> NEW.id THEN RAISE EXCEPTION 'advance %: a refund needs a decided underspend above zero and an advance_refund instance of this advance', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_matches'; END IF; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_variance_leg_guard BEFORE INSERT OR UPDATE OF topup_instance_id, refund_instance_id ON advance FOR EACH ROW EXECUTE FUNCTION advance_variance_leg_guard();
-- AN ACQUITTAL IS WRITTEN ONLY AT `received`, AND FROZEN ONCE DECIDED-- (Ruling A16): the advance's state is acknowledged with the payout open and-- no accrual open. The acquittal transaction sets acquitted_at while the-- state is still `received`, before workflow.Transition.CREATE FUNCTION advance_acquittal_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE st RECORD;BEGIN IF TG_OP = 'UPDATE' AND OLD.acquitted_at IS NOT NULL THEN RAISE EXCEPTION 'acquittal of advance % is decided: it never changes', OLD.advance_id USING ERRCODE = '23514', CONSTRAINT = 'advance_acquittal_is_final'; END IF; SELECT * INTO st FROM public.advance_state(NEW.org_id, NEW.advance_id); IF st.semantic IS DISTINCT FROM 'acknowledged' OR st.payout_open IS NOT TRUE OR st.accrual_open IS NOT FALSE THEN RAISE EXCEPTION 'advance % is not awaiting acquittal', NEW.advance_id USING ERRCODE = '23514', CONSTRAINT = 'advance_acquittal_while_received'; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_acquittal_guard BEFORE INSERT OR UPDATE ON advance_acquittal FOR EACH ROW EXECUTE FUNCTION advance_acquittal_guard();
-- +goose DownDROP TABLE advance_acquittal;DROP TABLE advance;DROP FUNCTION advance_acquittal_guard();DROP FUNCTION advance_variance_leg_guard();DROP FUNCTION advance_draft_guard();DROP FUNCTION advance_state(UUID, UUID);- Step 2: Write migration 00131.
-- +goose Up
-- SETTLEMENTS (Ruling A18): one row per payout of an `advance` or-- `advance_topup` instance, frozen. snapshot is payout.Snapshot's sealed-- JSON exactly as returned; Plan 7 never decrypts it. txid ties the row to-- the transaction that paid, which is what the payout rules read.CREATE TABLE advance_settlement ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), advance_id UUID NOT NULL, instance_id UUID NOT NULL, kind TEXT NOT NULL CONSTRAINT advance_settlement_kind_known CHECK (kind IN ('advance','advance_topup')), channel_id UUID NOT NULL, credit_account_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT advance_settlement_amount_positive CHECK (amount > 0), currency TEXT NOT NULL CONSTRAINT advance_settlement_currency_code CHECK (currency ~ '^[A-Z]{3}$'), snapshot JSONB NOT NULL, channel_key TEXT GENERATED ALWAYS AS (snapshot->'channel'->>'key') STORED, account_tail TEXT GENERATED ALWAYS AS (snapshot->>'account_tail') STORED, proof_document_id UUID, settled_by UUID NOT NULL, settled_at TIMESTAMPTZ NOT NULL, txid xid8 NOT NULL DEFAULT pg_current_xact_id(), CONSTRAINT advance_settlement_tenant_id UNIQUE (org_id, id), CONSTRAINT advance_settlement_snapshot_shape CHECK ( jsonb_typeof(snapshot) = 'object' AND snapshot->'version' = '1'::jsonb AND btrim(coalesce(snapshot->'channel'->>'key', '')) <> '' AND jsonb_typeof(snapshot->'fields') = 'array'), CONSTRAINT advance_settlement_masked_not_plain CHECK ( NOT jsonb_path_exists(snapshot, '$.fields[*] ? (@.sensitivity == "masked" && exists(@.value))')), CONSTRAINT advance_settlement_masked_sealed CHECK ( NOT jsonb_path_exists(snapshot, '$.fields[*] ? (@.sensitivity == "masked" && !(exists(@.sealed)))')), CONSTRAINT advance_settlement_advance_fk FOREIGN KEY (org_id, advance_id) REFERENCES advance (org_id, id), CONSTRAINT advance_settlement_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT advance_settlement_account_fk FOREIGN KEY (org_id, credit_account_id) REFERENCES account (org_id, id), CONSTRAINT advance_settlement_proof_fk FOREIGN KEY (org_id, proof_document_id) REFERENCES document (org_id, id), CONSTRAINT advance_settlement_settled_by_fk FOREIGN KEY (org_id, settled_by) REFERENCES organization_member (org_id, id));CREATE INDEX advance_settlement_by_advance ON advance_settlement (org_id, advance_id, settled_at DESC);CREATE INDEX advance_settlement_by_instance ON advance_settlement (org_id, instance_id, settled_at DESC);-- (RLS: ENABLE, FORCE, org_isolation USING + WITH CHECK.)REVOKE UPDATE, DELETE ON advance_settlement FROM erp_app;
-- KEY ROTATION (Plan 5 R45, contract §2.1): the one permitted change is a-- re-encryption of the sealed members. Own copy of Plan 5's normaliser, so-- this table does not depend on another plan's function.GRANT UPDATE (snapshot) ON advance_settlement TO erp_app;
CREATE FUNCTION advance_settlement_unsealed(p JSONB) RETURNS JSONB LANGUAGE sql IMMUTABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT (p - 'fields') || jsonb_build_object('fields', coalesce(( SELECT jsonb_agg((f - 'sealed') || jsonb_build_object('sealed', f ? 'sealed') ORDER BY n) FROM jsonb_array_elements(p->'fields') WITH ORDINALITY AS e(f, n)), '[]'::jsonb))$$;
CREATE FUNCTION advance_settlement_reseal_only() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.advance_settlement_unsealed(NEW.snapshot) IS DISTINCT FROM public.advance_settlement_unsealed(OLD.snapshot) THEN RAISE EXCEPTION 'advance settlement %: only a key-rotation reseal may change a snapshot', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'advance_settlement_reseal_only'; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_settlement_reseal_only BEFORE UPDATE ON advance_settlement FOR EACH ROW EXECUTE FUNCTION advance_settlement_reseal_only();
-- IDEMPOTENCY KEYS (Ruling A23). Operational: prunable.CREATE TABLE advance_idempotency_key ( org_id UUID NOT NULL REFERENCES organization (id), member_id UUID NOT NULL, key TEXT NOT NULL CONSTRAINT advance_idempotency_key_length CHECK (length(key) BETWEEN 1 AND 255), request_hash TEXT NOT NULL CONSTRAINT advance_idempotency_request_hash_hex CHECK (request_hash ~ '^[0-9a-f]{64}$'), status_code INT NOT NULL DEFAULT 0, response JSONB, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT advance_idempotency_pk PRIMARY KEY (org_id, member_id, key), CONSTRAINT advance_idempotency_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id));-- (RLS: ENABLE, FORCE, org_isolation USING + WITH CHECK.)
-- +goose DownDROP TABLE advance_idempotency_key;DROP TABLE advance_settlement;DROP FUNCTION advance_settlement_reseal_only();DROP FUNCTION advance_settlement_unsealed(JSONB);-
Step 3: Retention.
amendable+=advance,advance_acquittal(comment: “Plan 7: the advance and its acquittal (spec 7, 11A.4). Header edited only as a draft and an acquittal only atreceived, both DB-enforced (Rulings A12, A16); decided acquittals frozen; nothing erased.”).appendOnly+=advance_settlement(“Plan 7: what each advance or top-up payout was made against, frozen at settlement. erp_app holds UPDATE on the snapshot column only, for a key-rotation reseal whose trigger forbids any visible change (Ruling A18); has_table_privilege UPDATE is false”).operational+=advance_idempotency_key(“Plan 7: replay protection for transition requests; the decision log and audit trail are the record”).sqlc.yaml: append the twomoney.Rateoverrides.make generate. -
Step 4: Write the fixture —
advancestest/env.go.Newcallsexpensestest.New(t)and wraps it (the blank import ofinternal/platform/advancesis implicit: this package imports it forSourceKinds).NopPoster:posting.ResetForTest(); posting.Register(nopPoster{}); t.Cleanup(posting.ResetForTest)withnopPoster.Postreturning nil.Control:chart.ResolveControl(ctx, tx, advances.ControlKind)ine.Tx.ControlBalance(admin pool, explicit org predicates):
SELECT coalesce(sum(l.base_debit - l.base_credit), 0)::text FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id WHERE l.org_id = $1 AND a.control_kind = 'employee_advances' AND e.source_kind = ANY($2) AND e.source_id = $3- Step 5: Write the failing tests.
advancestest/env_test.go:
func TestTheWorldSeedsTheThreeAdvanceKinds(t *testing.T) { e := advancestest.New(t) // workflow.Latest for "advance", "advance_topup", "advance_refund" each // returns version 1; e.Control(t) is the account coded 1550; the // provisioning readiness for step 60 is done (the kinds are seeded).}schema_test.go (instances via e.StartInstance; header rows inserted as erp_app inside e.Tx; walking an instance via workflow.Transition with e.NopPoster(t), each move its own transaction — wave-3 §5):
func TestAnAdvanceHeaderIsEditableOnlyAsADraft(t *testing.T) { // PRESENCE: in draft, UPDATE advance SET purpose, amount, due_on succeed. // Walk draft -> pending_review (Staff): UPDATE purpose -> 23514 // 'advance_is_draft'; UPDATE revision = revision + 1 still succeeds.}func TestTheAcquittalDecisionTableIsMirroredInTheDatabase(t *testing.T) { // An advance walked to received. PRESENCE: a row with actual 1200, date, // document (e.Document as Staff), category meals inserts; a row with // actual 0 and reason "Trip cancelled" (MYR, rate 1, identity) inserts // (on a second advance). Then, one each, 23514 naming the constraint: // actual -1 -> advance_acquittal_actual_nonnegative; actual 900 without // receipt_date -> advance_acquittal_receipt_required; actual 0 without // reason -> advance_acquittal_reason_required; actual 0 with a document // -> advance_acquittal_reason_required; actual 0 at rate 4.2 -> // advance_acquittal_nothing_at_identity; actual 10 with base 0 -> // advance_acquittal_base_sign; reason "旅行取消" -> // advance_acquittal_reason_language.}func TestTheAcquittalTaxRecordMirrorsTheClaimLinesRules(t *testing.T) { // Ruling A30; each constraint named after the 00072 one it mirrors. // PRESENCE: actual 1200 with basis einvoice_company, TIN "C20880050010" // and uuid "F9D425P6DS7D8IU0000000000A" inserts. Then, one each (23514): // einvoice_company without uuid -> advance_acquittal_einvoice_identifiers; // relaxation_receipt without a reason -> advance_acquittal_relaxation_reason; // TIN "12345" -> advance_acquittal_tin_shape; uuid "abc" -> // advance_acquittal_einvoice_uuid_shape; basis "receipt" -> // advance_acquittal_basis_known; supplier_name "午餐店" -> // advance_acquittal_tax_text_language; actual 0 with a supplier TIN -> // advance_acquittal_nothing_has_no_tax_record; setting acquitted_at on a // 1200 row with no basis -> advance_acquittal_basis_when_decided.}func TestAnAcquittalIsWrittenOnlyWhileReceived(t *testing.T) { // An advance in draft: INSERT -> 'advance_acquittal_while_received'. // Walked to received: INSERT succeeds (PRESENCE). SET acquitted_at, // acquitted_by, expense_account_id: succeeds; any further UPDATE -> // 'advance_acquittal_is_final'.}func TestAReceiptBacksOneAdvance(t *testing.T) // the same document on a second advance's acquittal -> 23505 advance_acquittal_receipt_oncefunc TestAVarianceLegMustMatchItsAcquittal(t *testing.T) { // Advance 1000 at received, acquittal actual 1200 decided (acquitted_at // set). A started "advance_topup" instance for this advance (e.StartInstance): // PRESENCE: UPDATE advance SET topup_instance_id = it succeeds. // Then, one each: setting it again to another instance -> // 'advance_variance_leg_fixed'; on a second advance (actual 700) setting // topup_instance_id -> 'advance_variance_leg_matches'; a refund leg whose // instance is kind advance_topup -> 'advance_variance_leg_matches'; both // legs on one row -> 'advance_one_variance_leg'; an INSERT with a leg set // -> 'advance_variance_leg_matches'; a leg on an advance whose acquittal // is not decided -> 'advance_variance_leg_matches'.}func TestAdvanceRecordsCannotBeDeleted(t *testing.T) // 42501 on advance, advance_acquittal, advance_settlement, after PRESENCE of UPDATE advance.revisionfunc TestAnAdvanceSettlementNeverHoldsAMaskedValueInPlaintext(t *testing.T) { // Plan 5's hand-written version-1 snapshot (a payee_name and a masked, // sealed account_no). PRESENCE: inserts; channel_key and account_tail // generated. Then masked value present -> advance_settlement_masked_not_plain; // masked without sealed -> advance_settlement_masked_sealed; version 2 -> // advance_settlement_snapshot_shape; kind 'advance_refund' -> // advance_settlement_kind_known.}func TestOnlyAResealMayRewriteAnAdvanceSettlement(t *testing.T) { // PRESENCE: replacing only the sealed object succeeds. A display change // -> advance_settlement_reseal_only; UPDATE amount -> 42501; // has_table_privilege UPDATE false, has_column_privilege snapshot true.}func TestAdvanceIdempotencyKeysArePrunable(t *testing.T) // erp_app DELETE succeeds-
Step 6: Run.
make generate;go test ./internal/platform/advances/... ./internal/kernel/db/ -count=1→ PASS (the search-path guard sees the six functions; the constraint-name guard the new names; the tenant-FK guard the composite keys). -
Step 7: Sabotage. Drop the
v_actual <= NEW.base_amountclause fromadvance_variance_leg_guard→TestAVarianceLegMustMatchItsAcquittal’s underspend case goes red; quote; restore. Then drop theOLD.acquitted_at IS NOT NULLbranch fromadvance_acquittal_guard→ the “any further UPDATE” case ofTestAnAcquittalIsWrittenOnlyWhileReceivedgoes red; quote; restore. -
Step 8: Commit.
git add db/migrations/00130_advances.sql db/migrations/00131_advance_settlement.sql sqlc.yaml \ internal/kernel/db/retention_test.go internal/gen/sqlc internal/platform/advancesgit commit -m "Add advance, acquittal and settlement tables with the decision table in the database"Task 5: The advance header — create, read, list, edit the draft, who may see it
Section titled “Task 5: The advance header — create, read, list, edit the draft, who may see it”Waits for Task 1. Needs Task 4.
Files:
- Create:
internal/platform/advances/advance.go,internal/platform/advances/visibility.go,internal/platform/advances/cursor.go,internal/platform/advances/submission.go - Test:
internal/platform/advances/advance_test.go,internal/platform/advances/visibility_test.go,internal/platform/advances/submission_test.go
Interfaces:
-
Consumes:
numbering.Allocate,numbering.Format;workflow.Start,workflow.SetAmount,workflow.Get,workflow.AvailableTransitions,workflow.Decisions;fx.Resolve,fx.NoRateError,fx.SourceIdentity;company.Get;expenses.CheckRecordText(field, s string) error;audit.Write,activity.Record; the cursor encoding ofinternal/platform/expenses/cursor.go(copy it: a module does not share unexported helpers). -
Caller constraint (
workflow/instance.goTransitiondoc comment):workflow.Startandworkflow.SetAmounteach audit, so each runs in a transaction that does not also transition this advance. Create and draft edits are their own transactions. -
Produces:
type Advance struct {ID, InstanceID uuid.UUIDTopupInstanceID, RefundInstanceID *uuid.UUIDReference stringClaimantID, HolderPartyID uuid.UUIDPurpose, Currency stringAmount money.AmountFXRate money.RateFXRateSource stringBaseAmount money.AmountDueOn time.TimePayoutChannelID *uuid.UUIDRevision intState string // the advance instance's state keySemantic posting.Semantic // and its semanticCreatedAt, UpdatedAt time.Time}type AdvanceInput struct {ClaimantID uuid.UUID // the caller (Plan 5 R20: nobody files for someone else)Purpose stringCurrency stringAmount money.AmountDueOn time.TimePayoutChannelID *uuid.UUID}type AdvancePatch struct {Purpose *stringCurrency *stringAmount *money.AmountDueOn *time.TimePayoutChannelID *uuid.UUIDClearPayoutChannel bool}const MaxDueDays = 366func CreateAdvance(ctx context.Context, tx pgx.Tx, c clock.Clock, in AdvanceInput) (Advance, error)func GetAdvance(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Advance, error)func LockAdvance(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Advance, error) // FOR UPDATE OF the advance row onlyfunc UpdateAdvance(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, p AdvancePatch) (Advance, error)type ListFilter struct {Scope string // "mine" (default) or "all" (the handler checks read_all)Member uuid.UUID // the caller, for "mine"Limit int // 1..100, default 50Cursor *Cursor}func ListAdvances(ctx context.Context, tx pgx.Tx, f ListFilter) ([]Advance, *Cursor, error)func ToAct(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID) ([]Advance, error) // ≤ 200, oldest in state firstfunc CanRead(ctx context.Context, tx pgx.Tx, c clock.Clock, a Advance, member uuid.UUID, readAll bool) (bool, error)// InstanceIDs is the advance's instance, then its leg, if any.func (a Advance) InstanceIDs() []uuid.UUID// FieldError is one refused field (Plan 5 R43's detail shape).type FieldError struct{ Field, Rule, Detail string }func (e *FieldError) Error() string // "field=<Field> rule=<Rule>: <Detail>"type SubmissionError struct{ Problems []FieldError }func CheckSubmission(ctx context.Context, tx pgx.Tx, c clock.Clock, a Advance) errortype RevisionMismatchError struct{ Current int }var ErrNotFound, ErrNotClaimant, ErrNotDraft, ErrBadCursor error -
Step 1: Write the failing tests (
e := advancestest.New(t); a USD reference rate of 4.2000 for 2026-09-21 stored withfx.StoreReferencein the test, as Plan 5’s line tests do):
advance_test.go:
func TestAnAdvanceIsFiledWithAReferenceAndAnInstance(t *testing.T) { // CreateAdvance{Staff, "Site visit Kuantan", "MYR", 1000.00, due 2026-10-15}: // Reference "AD-2026-0001"; an "advance" instance in "draft", amount // 1000.0000 base, reporter Staff, source_id = advance id; HolderPartyID = // Staff's party; FXRate 1, FXRateSource "identity", BaseAmount 1000.0000; // Revision 1; one audit row (entity "advance", action "create"); one // activity row (verb "created").}func TestAForeignCurrencyAdvanceFreezesItsBaseAmount(t *testing.T) { // USD 300.00 -> FXRate 4.2000000000, source "reference", BaseAmount // 1260.0000, instance amount 1260.0000. UpdateAdvance{Purpose} leaves the // rate alone; UpdateAdvance{Amount: 310} re-resolves (1302.0000) and the // instance amount follows (workflow.SetAmount). No rate for "JPY" -> // *FieldError{currency, no_rate}.}func TestReferencesAreGaplessAcrossAnAbortedCreate(t *testing.T) { // One create in a transaction that then returns an error (rolled back); // the next create gets AD-2026-0001 (spec 20.1.3).}func TestOnlyTheClaimantEditsTheirDraft(t *testing.T) // PRESENCE Staff edits; Owner -> ErrNotClaimantfunc TestADraftEditNeedsTheCurrentRevision(t *testing.T) // expectedRevision 0 -> *RevisionMismatchError{Current: 1}func TestASubmittedAdvanceCannotBeEdited(t *testing.T) { // Walk draft -> pending_review (e.NopPoster; workflow.Transition in its // own tx). UpdateAdvance -> ErrNotDraft; the DB trigger refuses a raw // UPDATE as well (Task 4).}func TestADueDateMustBeAhead(t *testing.T) { // PRESENCE: due today (2026-09-21) and due today + 366 accepted. Due // 2026-09-20 -> *FieldError{due_on, past}; due today + 367 -> // *FieldError{due_on, too_far}.}func TestPurposeIsRecordText(t *testing.T) // "工地考察" -> *FieldError{purpose, language}; blank -> {purpose, required}; amount 0 -> {amount, positive}func TestListingPagesWithACursor(t *testing.T) // 3 advances; Limit 2 -> 2 and a cursor; next page -> 1 and nil; Scope "mine" hides another member'svisibility_test.go:
func TestWhoMayReadAnAdvance(t *testing.T) { // PRESENCE: Staff (claimant) true; readAll true for Outsider. // Submitted: Owner (can review) true; Outsider false. // After Owner reviews and approves, a member who reviewed keeps the view // (decision log). And a leg: once a top-up exists (Task 8's walk; here // seed it with e.StartInstance + the admin UPDATE the trigger allows), // a member allowed on the leg alone sees the advance.}func TestTheQueueShowsWhatICanDecide(t *testing.T) // Staff submits: ToAct(Owner) has it; ToAct(Staff) emptysubmission_test.go:
func TestACompleteDraftPassesSubmission(t *testing.T) // nilfunc TestAStaleDueDateStopsSubmission(t *testing.T) { // Create with due 2026-09-25; move the fixture clock to 2026-09-26 (a new // clock.Fixed in the call); CheckSubmission -> *SubmissionError with // {due_on, past}.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
CreateAdvance: org from context; validate purpose (strings.TrimSpace, ≤ 500,expenses.CheckRecordText("purpose", …)mapped to*FieldError{purpose, language}), currency shape, amount > 0, due date in[today, today+MaxDueDays];fx.Resolve(currency, base, today)(*fx.NoRateError→{currency, no_rate});n := numbering.Allocate(ctx, tx, "advance", year);id := uuid.New();inst := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: Kind, SourceID: id, Reporter: in.ClaimantID, Amount: base}); partySELECT party_id FROM organization_member WHERE org_id = $1 AND id = $2;INSERT INTO advance (id, org_id, reference, claimant_member_id, holder_party_id, instance_id, purpose, currency, amount, fx_rate, fx_rate_source, base_amount, due_on, payout_channel_id, created_at, updated_at) VALUES (…);audit.Write{EntityKind: EntityAdvance, Action: "create", ActorID: &claimant, After: advance};activity.Record{Subject: EntityAdvance, Verb: "created", Summary: reference + " " + purpose}.- Reads: one
advanceSelectjoiningworkflow_instanceandworkflow_stateread-only forState/Semantic(Plan 5 R40’s pattern), every table tied to$1.LockAdvanceappendsFOR UPDATE OF a. UpdateAdvance:LockAdvance; claimant only;Semantic == posting.DraftelseErrNotDraft; revision; re-validate touched fields; re-resolve FX only when amount or currency changed (A11);UPDATE advance SET …, revision = revision + 1, updated_at = $n; if base changed,workflow.SetAmount(ctx, tx, c, a.InstanceID, base)(locks the instance after the advance row — the plan’s order); auditupdatewith before/after.ListAdvances: keyset on(created_at DESC, id DESC)exactly asexpenses.ListClaims.ToAct: candidates are advances any of whose instances is in a non-terminal, non-draft state, oldestentered_state_atfirst, filtered bymayActoverInstanceIDs(), capped at 200.CanRead: Ruling A22 — claimant;readAll;mayActon any instance (an allowedworkflow.AvailableTransitionsentry); anyworkflow.Decisionsrow on any instance withActororAuthorityMemberequal to the member.CheckSubmission: collects every problem; today onlydue_on(past) — the header CHECKs cover the rest.
-
Step 4: Run.
go test ./internal/platform/advances/ -count=1→ PASS. Sabotage: remove the decision-log branch ofCanRead→ the “keeps the view” case goes red; quote; restore. -
Step 5: Commit —
git commit -m "File, edit, list and read advances with frozen base amounts and gapless references".
Task 6: The acquittal record and the receipt binder
Section titled “Task 6: The acquittal record and the receipt binder”Waits for Task 1 (including row 14: taxrecord must be on the base). Needs Tasks 2, 5. Runs in parallel with Task 7 (disjoint files).
Files:
- Create:
internal/platform/advances/acquittal.go,internal/platform/advances/binder.go - Test:
internal/platform/advances/acquittal_test.go,internal/platform/advances/binder_test.go
Interfaces:
-
Consumes:
LockAdvance,GetAdvance,CanRead(Task 5);DecideAcquittal(Task 2);documents.Lock,documents.Get,documents.StatusVerified,documents.ErrNotFound; the binder API as built (internal/platform/documents/binder.go):documents.Binder,documents.Binding,documents.RegisterBinder,documents.BoundElsewhere,documents.MayRead(ctx, tx, c, documentID, memberID);expenses.GetCategory,expenses.UsableAccount,expenses.ErrNotFound,expenses.CheckRecordText;taxrecord.Normalize,taxrecord.CheckEmployeeNamed,taxrecord.Record,*taxrecord.FieldError(names per Task 1 row 14);expenses.AttachDocumentandexpenses.ErrDocumentBoundElsewhere(tests only);fx.Resolve;workflow.Get(Instance.PayoutOpen,AccrualOpen);member.Can. -
Produces:
type Acquittal struct {ID, AdvanceID uuid.UUIDActualCurrency stringActualAmount money.AmountActualFXRate money.RateActualFXRateSource stringActualBase money.AmountReceiptDate *time.TimeReceiptDocumentID *uuid.UUIDCategoryID *uuid.UUIDReason *stringTaxRecord taxrecord.Record // §6.7 (Ruling A30); zero for a zero acquittalExpenseAccountID *uuid.UUID // set when decided (Task 8)RecordedBy uuid.UUIDAcquittedAt *time.TimeAcquittedBy *uuid.UUIDCreatedAt, UpdatedAt time.Time}type AcquittalInput struct {ActualCurrency string // "" means the base currencyActualAmount money.AmountReceiptDate *time.TimeReceiptDocumentID *uuid.UUIDCategoryID *uuid.UUIDReason stringTaxRecord taxrecord.Record // document basis, supplier name/TIN, e-Invoice UUID, relaxation reason}// RecordAcquittal saves the claimant's acquittal while the advance is at// received, binding its receipt (Rulings A8, A15, A16) and returning the// decision it implies now.func RecordAcquittal(ctx context.Context, tx pgx.Tx, c clock.Clock, advanceID, actor uuid.UUID,expectedRevision int, in AcquittalInput) (Acquittal, Advance, AcquittalDecision, error)func GetAcquittal(ctx context.Context, tx pgx.Tx, advanceID uuid.UUID) (Acquittal, error) // ErrNoAcquittalfunc lockAcquittal(ctx context.Context, tx pgx.Tx, orgID, advanceID uuid.UUID) (Acquittal, error)// Facts assembles DecideAcquittal's input from the stored row.func (q Acquittal) Facts(approved money.Amount, today time.Time) AcquittalFacts// ErrDocumentBoundElsewhere / DocumentBoundElsewhereError: the receipt// already backs another record (contract §1.4) — the shape expenses built.var ErrDocumentBoundElsewhere errortype DocumentBoundElsewhereError struct{ Bindings []documents.Binding }func (e *DocumentBoundElsewhereError) Error() string // ErrDocumentBoundElsewhere's text + ": <kind>, …" — kinds only (contract §8.3)func (e *DocumentBoundElsewhereError) Is(target error) bool // target == ErrDocumentBoundElsewherevar ErrNoAcquittal, ErrNotAcquittable, ErrNotYourDocument, ErrDocumentNotVerified, ErrCategoryUnusable errorbinder.go: an unexportedbinderimplementingdocuments.BinderwithKind() == BinderKind, registered frominit()(documents.RegisterBinder(documentBinder{}), Rulings A15, A33). -
Step 1: Write the failing tests. A helper in
acquittal_test.gowalks an advance of MYR 1,000 toreceived(NopPoster; create; submit as Staff; review, approve, pay as Owner through the bank-transfer channel with Staff’s payout profile —settledoes not exist yet, so seed the payout transition withworkflow.Transitiondirectly, one move per transaction; acknowledge as Staff).doc := e.Document(t, e.Staff, "application/pdf", []byte("receipt")).
func TestRecordingAnAcquittalWithAReceipt(t *testing.T) { // RecordAcquittal{1200.00, receipt 2026-09-20, doc, meals} -> decision // OutcomeTopup, Variance 200.0000, LegAmount 200.0000; the row has // ActualCurrency MYR, rate 1 identity, ActualBase 1200.0000, RecordedBy // Staff, AcquittedAt nil; the advance's Revision moved 1 -> 2; one audit // row (entity "advance_acquittal", action "record", After names the // document id); one activity row (verb "acquittal_recorded").}func TestRecordingAgainReplacesTheRecord(t *testing.T) { // A second RecordAcquittal (revision 2) with actual 950 -> OutcomeRefund; // still one row (advance_acquittal_once); audit Before/After both present.}func TestAnAcquittalAtZeroNeedsAReasonAndNoReceipt(t *testing.T) { // {0, reason "Trip cancelled"} -> OutcomeReturned, Variance -1000.0000. // {0, no reason} -> *AcquittalError{reason, required}; {0, with doc} -> // {receipt_document_id, none_exists}. No row written by the refusals.}func TestAnAcquittalIsRecordedOnlyWhileReceived(t *testing.T) { // PRESENCE at received: ok. A draft advance -> ErrNotAcquittable; an // advance at paid_out (not yet acknowledged) -> ErrNotAcquittable.}func TestOnlyTheClaimantRecords(t *testing.T) // Owner -> ErrNotClaimantfunc TestAReceiptMustBeTheClaimantsAndVerified(t *testing.T) { // A document uploaded by Owner -> ErrNotYourDocument; an unverified // (pending) document of Staff's -> ErrDocumentNotVerified; an unknown id // -> ErrNotFound. PRESENCE first: Staff's verified document is accepted.}func TestAReceiptOnAClaimCannotBackAnAdvance(t *testing.T) { // PRESENCE: a fresh document binds. Then attach doc2 to a Plan 5 claim // line (expenses.CreateClaim + AddLine + AttachDocument as Staff) and // RecordAcquittal with doc2 -> *DocumentBoundElsewhereError whose // Bindings[0].Kind is "expense_claim"; its Error() names the kind and // NOT the claim's reference (contract §8.3).}func TestAReceiptOnAnAdvanceCannotBackAClaim(t *testing.T) { // doc bound to the advance; expenses.AttachDocument(doc) -> // errors.Is(err, expenses.ErrDocumentBoundElsewhere) — the base's // check reaches this plan's binder.}func TestReplacingTheReceiptReleasesTheOld(t *testing.T) { // Record with doc, then with doc2: doc is free — expenses.AttachDocument // of doc onto a claim line succeeds.}func TestAForeignReceiptIsConvertedAtItsDate(t *testing.T) { // USD 300.00 dated 2026-09-19 with a stored reference of 4.2000 for that // day -> ActualFXRate 4.2, source "reference", ActualBase 1260.0000, // decision OutcomeTopup 260.0000.}func TestAnUnusableCategoryIsRefused(t *testing.T) // a category whose account was cleared -> ErrCategoryUnusablefunc TestTheAcquittalCarriesTheTaxRecord(t *testing.T) { // Ruling A30. PRESENCE: basis einvoice_company, supplier "Kedai Makan // Siti", TIN " c20880050010 " and uuid "f9d425p6ds7d8iu0000000000a" are // saved normalized (upper-cased, trimmed) — taxrecord's work, not a copy. // Refusals come back as *taxrecord.FieldError with Plan 5's codes: // einvoice_company without uuid -> {einvoice_uuid, einvoice_identifiers}; // TIN "12345" -> {supplier_tin, tin_shape}; relaxation_receipt without a // reason -> {relaxation_reason, relaxation_reason}; einvoice_employee on // meals (employee_named_document_allowed false) -> {document_basis, // employee_named_not_allowed}; the same after the category allows it -> // accepted. A zero acquittal with any tax field -> refused ({document_basis // or the field, none_exists}); no row written by any refusal. // Recording with no basis at all is allowed (completeness is checked at // the decision, Task 8).}func TestTheBindingPathLocksTheDocument(t *testing.T) { // Tx A: documents.Lock(doc) and hold (10 s context). Tx B: // RecordAcquittal(doc) must block, not succeed; release A; B completes. // Both goroutines bounded; cleanup in t.Cleanup.}binder_test.go:
func TestTheAdvanceBinderReportsItsRecord(t *testing.T) { // After recording with doc: documents.BoundElsewhere(doc, except // Binding{Kind: "expense_claim", RecordID: uuid.New()}) contains // {Kind "advance", RecordID advance.ID, Reference "AD-2026-0001"}; // with except {Kind "advance", RecordID advance.ID} it is empty.}func TestAnApproverMayReadTheAcquittalReceipt(t *testing.T) { // documents.MayRead(ctx, tx, e.Clock, doc, member). PRESENCE: Staff true // (uploader). Owner — who may take received -> acquitted — true through // this binder. Outsider false; the same Outsider after a role grants // advances.advance.read_all -> true (the binder honours its own // read_all, contract §8.3, Ruling A33). A document bound to no advance // -> false for that read_all holder (read_all opens advances' documents, // not every document).}-
Step 2: Run and watch fail.
-
Step 3: Implement
RecordAcquittal— the binding path, in this order (Ruling A15, contract §7: own record first, then the document, thenBoundElsewhere):a := LockAdvance(advanceID);actor != a.ClaimantID→ErrNotClaimant; revision →*RevisionMismatchError.inst := workflow.Get(a.InstanceID); unlessa.Semantic == posting.Acknowledged && inst.PayoutOpen && !inst.AccrualOpen→ErrNotAcquittable.prev, err := lockAcquittal(org, a.ID)(ErrNoAcquittalis fine: first record).- If
in.ReceiptDocumentID != nil:doc := documents.Lock(…)(documents.ErrNotFound→ErrNotFound);doc.UploadedBy != a.ClaimantID→ErrNotYourDocument;doc.Status != documents.StatusVerified→ErrDocumentNotVerified;bs := documents.BoundElsewhere(ctx, tx, doc.ID, documents.Binding{Kind: BinderKind, RecordID: a.ID});len(bs) > 0→&DocumentBoundElsewhereError{Bindings: bs}. - If
in.CategoryID != nil:cat := expenses.GetCategory;expenses.UsableAccount(ctx, tx, cat)error →ErrCategoryUnusable(wrapped). - Reason: trim; if non-empty
expenses.CheckRecordText("reason", …). 6b. Tax record (Ruling A30): a zero acquittal with any tax field set →&AcquittalError{Field: <the first set field>, Rule: "none_exists"}; otherwiserec, err := taxrecord.Normalize(in.TaxRecord)(errors returned as they are —*taxrecord.FieldError), thentaxrecord.CheckEmployeeNamed(rec.DocumentBasis, cat.EmployeeNamedDocumentAllowed)when a category is named. Storerec. - FX (A11): currency
""→ base.ActualAmount.IsZero()→ base, rate 1, identity. Otherwisefx.Resolve(currency, base, *in.ReceiptDate)when a date is present (a missing date is DecideAcquittal’s refusal, so resolve only when it is set; if absent, pass zero base and let the decision refuse). dec, err := DecideAcquittal(AcquittalFacts{Approved: a.BaseAmount, …, Today: clock.TodayUTC(c)})— refuse before writing.- Write:
INSERT …whenprevis absent, elseUPDATE advance_acquittal SET … , updated_at = $n WHERE org_id = $1 AND advance_id = $2;UPDATE advance SET revision = revision + 1, updated_at = $n WHERE org_id = $1 AND id = $2(not a guarded column). audit.Write{EntityKind: EntityAcquittal, EntityID: row.ID, Action: "record", ActorID: &actor, Before: prev-or-nil, After: row};activity.Record{Subject: EntityAdvance, SubjectID: a.ID, Verb: "acquittal_recorded"}.
Implement the binder (the built
internal/platform/expenses/binder.gois the pattern):BoundTo— every advance the document backs as an acquittal receipt or a settlement proof (contract §7), oneBindingper advance:SELECT a.id, a.reference FROM advance aWHERE a.org_id = $1AND a.id IN (SELECT q.advance_id FROM advance_acquittal q WHERE q.org_id = $1 AND q.receipt_document_id = $2UNIONSELECT s.advance_id FROM advance_settlement s WHERE s.org_id = $1 AND s.proof_document_id = $2)ORDER BY a.referenceMayRead(ctx, tx, c, documentID, memberID): no bound advance → false;member.Can(ctx, tx, memberID, ReadAllPermission)→ true; else for each bound advance,GetAdvancethenCanRead(ctx, tx, c, a, memberID, false); true on the first yes (Ruling A33). -
Step 4: Run.
go test ./internal/platform/advances/ -count=1→ PASS. Sabotage: skip theBoundElsewherecall →TestAReceiptOnAClaimCannotBackAnAdvancered; quote; restore. -
Step 5: Commit —
git commit -m "Record advance acquittals and bind their receipts through the document binder".
Task 7: Settlement — the sealed snapshot of an advance or top-up payout, external channels only
Section titled “Task 7: Settlement — the sealed snapshot of an advance or top-up payout, external channels only”Waits for Task 1. Needs Task 5. Runs in parallel with Task 6.
Files:
- Create:
internal/platform/advances/settlement.go,internal/platform/advances/nosnapshotopen_test.go - Test:
internal/platform/advances/settlement_test.go
Interfaces:
-
Consumes:
payout.GetChannel(ctx, tx, id) (payout.Channel, error),payout.Snapshot(ctx, tx, channelID, memberID, claimType string, amount money.Amount, currency string) (json.RawMessage, error),payout.SettlementSnapshot,payout.Names,payout.BehaviourPettyCash;documents.Get; tests:e.Channel,e.SeedChannel(t, payouttest.ChannelRow{…}),e.PayoutProfile,e.CreditAccount(fromexpensestest). -
Produces:
type Settlement struct {ID, AdvanceID, InstanceID, ChannelID, CreditAccountID uuid.UUIDKind string // Kind or KindTopupAmount money.AmountCurrency stringSnapshot json.RawMessage // sealed, byte-for-byte; never logged, audited or servedChannelKey stringAccountTail *stringProofDocumentID *uuid.UUIDSettledBy uuid.UUIDSettledAt time.Time}type SettlementView struct {ID, ChannelID uuid.UUIDKind stringChannelKey, ChannelName stringAccountTail *stringAmount money.AmountCurrency stringFields []SettlementFieldViewProofDocumentID *uuid.UUIDSettledBy uuid.UUIDSettledAt time.Time}type SettlementFieldView struct {Key stringLabel payout.NamesDisplay string}func (s Settlement) View() (SettlementView, error) // unmarshal only; never Value, never Sealedfunc Settlements(ctx context.Context, tx pgx.Tx, advanceID uuid.UUID) ([]Settlement, error) // newest first, both kindsfunc SettlementInThisTx(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID) (Settlement, error) // ErrNoSettlementtype settleInput struct {Advance AdvanceKind string // Kind or KindTopup: the claim-type key (Ruling A18)InstanceID uuid.UUIDAmount money.Amount // baseChannelID *uuid.UUID // the payer's choice; nil falls back to Advance.PayoutChannelIDProofDocumentID *uuid.UUIDActor uuid.UUID}func settle(ctx context.Context, tx pgx.Tx, c clock.Clock, in settleInput) (*Settlement, error)var ErrChannelRequired, ErrChannelInactive, ErrChannelPettyCash, ErrChannelCurrency,ErrChannelClaimType, ErrChannelMaxAmount, ErrProofRequired, ErrProofNotVerified, ErrNoSettlement error -
Step 1: Write the failing tests (internal tests calling
settleinside onee.Tx; a draft advance of MYR 1,000 is enough — the FK needs only the instance;ch := e.Channel(t, "bank_transfer");e.PayoutProfile(t, e.Staff, ch, map[string]string{"payee_name": "Siti Aminah", "bank": "maybank", "account_no": "5140 1234 6789"})):
func TestSettlingAnAdvanceFreezesTheSealedSnapshot(t *testing.T) { // settle{Kind: "advance", Amount 1000, ChannelID &ch, Actor Owner}: one // row; CreditAccountID == e.CreditAccount(ch); Kind "advance"; // InstanceID the advance's; ChannelKey "bank_transfer"; *AccountTail // "6789". The stored bytes contain neither "514012346789" nor // "5140 1234 6789"; unmarshalled, account_no has Sealed != nil and // Value "". SettlementInThisTx(instance) in the same tx returns it.}func TestAPettyCashChannelIsRefused(t *testing.T) { // PRESENCE: bank_transfer settles. e.Channel(t, "petty_cash") -> // ErrChannelPettyCash (Ruling A18, contract §2.1); no row.}func TestTheWorkflowKindIsTheClaimType(t *testing.T) { // A seeded channel with AllowedClaimTypes {"advance"}: Kind "advance" // settles; Kind "advance_topup" -> ErrChannelClaimType. With // {"expense_claim"}: "advance" -> ErrChannelClaimType.}func TestTheOtherChannelChecks(t *testing.T) { // One case each, PRESENCE first: inactive -> ErrChannelInactive; MaxAmount // below the amount -> ErrChannelMaxAmount; a channel row whose // AllowedCurrencies is {"USD"} (e.SeedChannel) -> ErrChannelCurrency; // RequiresSettlementProof // without proof -> ErrProofRequired; a pending proof -> ErrProofNotVerified; // no channel anywhere -> ErrChannelRequired; the advance's preferred // channel is used when the input names none.}func TestPlan6sRefusalsPassThroughTyped(t *testing.T) // a claimant with no profile -> errors.Is(err, payout.ErrNoProfile)func TestASettlementLeavesThisPackageMasked(t *testing.T) { // Settlements(advance)[0].View(): Kind, ChannelKey, AccountTail "6789", // fields payee_name/bank/account_no with displays; json.Marshal(view) // contains neither the full number nor "\"sealed\"" nor "\"value\"".}func TestASettlementIsVisibleOnlyToItsOwnTransaction(t *testing.T) // SettlementInThisTx in a later tx -> ErrNoSettlement; Settlements lists itnosnapshotopen_test.go:
// TestPlan7NeverOpensASnapshot: Plan 7 renders masked displays only (Ruling A18).func TestPlan7NeverOpensASnapshot(t *testing.T) { var scanned int for _, dir := range []string{"../../platform/advances", "../../modules/advances"} { // walk *.go (tests too, skipping this file); count files; fail // naming file:line on any occurrence of "OpenSnapshot". A missing // modules/advances directory (before Task 14) is skipped, not failed. } require.Greater(t, scanned, 5, "PRESENCE: the walk found the package's files")}-
Step 2: Run and watch fail.
-
Step 3: Implement
settle—internal/platform/expenses/settlement.go’ssettlewith these differences: channel from input, elseAdvance.PayoutChannelID, elseErrChannelRequired;ch.SettlementBehaviour == payout.BehaviourPettyCash→ErrChannelPettyCashbeforepayout.Snapshot; claim typein.Kind; currency = the base currency (company.Get), amountin.Amount;payout.Snapshot(ctx, tx, ch.ID, in.Advance.ClaimantID, in.Kind, in.Amount, base), error wrapped with%wand nothing else;INSERT INTO advance_settlement (org_id, advance_id, instance_id, kind, channel_id, credit_account_id, amount, currency, snapshot, proof_document_id, settled_by, settled_at) VALUES (…, $n::jsonb, …) RETURNING id, channel_key, account_tail. No audit here: the transition’s audit row namessettlement_id(Task 8).SettlementInThisTx:… WHERE org_id = $1 AND instance_id = $2 AND txid = pg_current_xact_id() ORDER BY settled_at DESC LIMIT 1.View:json.Unmarshalintopayout.SettlementSnapshot, copy channel key/name, account tail and each field’s key, label, display. -
Step 4: Run. PASS. Sabotage: delete the petty-cash branch →
TestAPettyCashChannelIsRefusedred (a row is written crediting cash in hand); quote; restore. -
Step 5: Commit —
git commit -m "Freeze sealed payout snapshots for advances and top-ups; refuse petty cash".
Task 8: The transition service — branch by sign, start the leg, require the overspend approval
Section titled “Task 8: The transition service — branch by sign, start the leg, require the overspend approval”Waits for Task 1. Needs Tasks 5, 6, 7.
Files:
- Create:
internal/platform/advances/transition.go,internal/platform/advances/overspend.go,internal/platform/advances/available.go - Modify:
internal/platform/advances/advancestest/env.go(walk helpers below) - Test:
internal/platform/advances/transition_test.go,internal/platform/advances/overspend_test.go,internal/platform/advances/available_test.go
Interfaces:
-
Consumes:
LockAdvance,GetAdvance,CheckSubmission(5);lockAcquittal,Acquittal.Facts(6);settle(7);DecideAcquittal(2);expenses.GetCategory,expenses.UsableAccount;workflow.Get,workflow.Load,workflow.Transition,workflow.Start,workflow.AvailableTransitions,workflow.Decisions,workflow.ErrStaleState,workflow.RefuseNotRoutable,workflow.Refusal,Instance.PayoutOpen. -
Caller constraint (wave-2 §3, wave-3 §5;
workflow/instance.goTransitiondoc): nothing in the transaction audits beforeworkflow.Transition;workflow.Startof the leg comes after it (Ruling A21). -
Produces:
type TransitionInput struct {AdvanceID uuid.UUIDKind string // Kind (default when ""), KindTopup or KindRefund: which instance movesFrom, To stringActor uuid.UUIDReason stringPayoutChannelID *uuid.UUID // entering a payout state of advance or advance_topupProofDocumentID *uuid.UUID}// Transition moves one of the advance's instances along one edge. The// caller's transaction must not commit on error; the handler maps InTx's// error through ledger.MapError.func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Advance, workflow.Decision, error)var ErrNoSuchLeg, ErrAcquittalBranch, ErrOverspendNotApproved error// OverspendApproved is Ruling A10, pure: decisions oldest first; draftKey// is the leg's draft state key; reporter the claimant.func OverspendApproved(ds []workflow.Decision, draftKey string, reporter uuid.UUID) boolconst RefuseAcquittalBranch = "acquittal_branch"// Available is workflow.AvailableTransitions for one of the advance's// instances, with the acquittal exit that does not match the recorded// acquittal marked not allowed (Ruling A9).func Available(ctx context.Context, tx pgx.Tx, c clock.Clock, a Advance, kind string, actor uuid.UUID) ([]workflow.Available, error)package advancestest // additionstype Step struct{ Kind, From, To string; Actor uuid.UUID; Reason string; Channel *uuid.UUID }// Move runs one advances.Transition in its own transaction.func (e *Env) Move(t *testing.T, id uuid.UUID, s Step) advances.Advance// Received files MYR amount for Staff (due 2026-10-15) and walks it// submit → review → approve → pay (bank_transfer; Staff's profile saved) →// acknowledge, one transaction per move.func (e *Env) Received(t *testing.T, amount string) advances.Advance// Record saves an acquittal of actual (MYR, receipt dated 2026-09-20, a// fresh verified document, category "meals", tax record: basis// einvoice_company, supplier "Kedai Makan Siti", TIN "C20880050010",// uuid "F9D425P6DS7D8IU0000000000A"); actual "0" records the reason// "Trip cancelled" and no tax record instead.func (e *Env) Record(t *testing.T, a advances.Advance, actual string) advances.Advance -
Step 1: Write the failing tests.
overspend_test.go (pure):
func TestOverspendApproved(t *testing.T) { // reporter R, approver A; decision(from, to, gate, actor, authority). // Table: // none -> false // draft->topup_pending (reporter, R) -> false // + topup_pending->topup_approved (target, A, A) -> true // + an approval whose authority is R (delegated to A) -> false // submit, approve(A), reject(advance, A), revise(R), // submit(R) -> false (reset on leaving draft) // … then approve(A) again -> true // an approval by R itself (target, R, R) -> false}transition_test.go (e.NopPoster(t) — the posting rules arrive in Tasks 10–11; these tests prove the advance side only):
func TestSubmittingChecksTheAdvanceThenTheWorkflow(t *testing.T) { // PRESENCE: draft -> pending_review (Staff) ok; one audit row (entity // "advance", action "transition", After {kind, state, decision_id}); // activity verb "submitted". A draft with a stale due date -> // *SubmissionError, still draft, no decision.}func TestPayingFreezesASettlementBeforeTheEngineMoves(t *testing.T) { // approved -> paid_out (Owner, bank_transfer): one settlement row with // Kind "advance" and Amount 1000.0000; the audit row's After names // settlement_id; activity verb "paid". With the petty_cash channel -> // ErrChannelPettyCash, still approved, no settlement, no decision.}func TestAuthorityIsCheckedBeforeEvidence(t *testing.T) { // At received with NO acquittal recorded: Staff tries received -> // acquitted -> *workflow.RefusedError (self-approval / not granted), NOT // ErrNoAcquittal (Plan 5 R21's order: a non-approver learns nothing). // Owner -> ErrNoAcquittal.}func TestTheAcquittalPicksTheBranch(t *testing.T) { // One advance of 1000 per row, walked to received and recorded at actual: // 1000 -> acquitted ok, no leg; returned -> ErrAcquittalBranch // 1200 -> acquitted ok, TopupInstanceID set: an "advance_topup" instance // in "draft", amount 200.0000, reporter Staff, source = advance // 700 -> acquitted ok, RefundInstanceID set: "advance_refund" in draft, 300.0000 // 0 -> acquitted -> ErrAcquittalBranch; returned ok, no leg // Each refusal leaves the advance at received with no decision row.}func TestAnAcquittalWithoutATaxBasisCannotBeDecided(t *testing.T) { // Ruling A30's completeness half. PRESENCE: recorded with basis // einvoice_company (TIN, uuid) -> received -> acquitted ok. Another // advance recorded at 1200 with no basis -> *taxrecord.FieldError // {document_basis, required}; still received; no decision. A zero // acquittal needs none and returns fine.}func TestTheAcquittalFreezesTheRecordAndItsAccount(t *testing.T) { // After received -> acquitted (1200): AcquittedAt set, AcquittedBy Owner, // ExpenseAccountID == meals' account; RecordAcquittal now -> // ErrNotAcquittable (and the DB trigger would refuse too). // After received -> returned (0): AcquittedAt set, ExpenseAccountID nil.}func TestTheLegStartsInTheAcquittalTransactionAndMovesInALaterOne(t *testing.T) { // The acquittal tx's audit rows, in seq order: workflow_instance // "transition" (the advance), workflow_instance "start" (the leg), // advance "transition" — nothing audited before the engine moved. // A later tx: Staff moves the leg draft -> topup_pending (Kind // "advance_topup") ok.}func TestATopupIsPaidOnlyAfterTheExcessIsApproved(t *testing.T) { // The 1200 advance's leg: submit (Staff); approve_excess (Owner); pay // (Owner, bank_transfer): a settlement row Kind "advance_topup", Amount // 200.0000, InstanceID the leg's. (The ABSENCE is OverspendApproved's // table above, and the sabotage below.)}func TestATopupRefusesPettyCash(t *testing.T) // topup_approved -> topup_paid with petty_cash -> ErrChannelPettyCashfunc TestASettlementProofCountsAsBound(t *testing.T) { // Contract §7. Pay advance A through a seeded channel that requires proof, // with proof P (a verified document Staff uploaded, for the test's sake): // documents.BoundElsewhere(P, except {expense_claim, uuid.New()}) names A. // A second advance B settles with the same P (settling never calls // BoundElsewhere — one slip may cover several payments). B's acquittal // cannot use P as its receipt: *DocumentBoundElsewhereError naming A.}func TestAnUnknownLegIsRefused(t *testing.T) // Kind "advance_topup" on an advance with no top-up -> ErrNoSuchLeg; Kind "nonsense" -> ErrNoSuchLegfunc TestTheAdvanceLockIsTakenBeforeTheInstanceLock(t *testing.T) { // Tx A: LockAdvance and hold (10 s). Tx B: Transition(submit) blocks, does // not deadlock and does not succeed; release A; B completes. Bounded.}available_test.go:
func TestTheMismatchedAcquittalExitIsNotAllowed(t *testing.T) { // At received, recorded at 1200, as Owner: Available lists acquitted // (allowed) and returned (allowed false, Refusal.Code "acquittal_branch"). // Recorded at 0: the reverse. With no acquittal recorded: both exits not // allowed with "acquittal_branch" for the owner, and the claimant's view // keeps the engine's own refusals untouched.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
overspend.go:
func OverspendApproved(ds []workflow.Decision, draftKey string, reporter uuid.UUID) bool { approved := false for _, d := range ds { if d.From == draftKey { approved = false // every departure from draft starts a new request } if d.Gate == workflow.GateTarget && d.Actor != reporter && d.AuthorityMember != reporter { approved = true } } return approved}transition.go — Transition, in this order (Ruling A21, the Global Constraints’ lock order):
a := LockAdvance(in.AdvanceID).- Pick the instance:
Kind/""→a.InstanceID;KindTopup→*a.TopupInstanceID;KindRefund→*a.RefundInstanceID; nil or unknown →ErrNoSuchLeg. inst := workflow.Get(no lock),def := workflow.Load(inst.DefinitionID),from, _ := def.State(in.From),to, known := def.State(in.To).examine := known && inst.StateKey == in.From && mayTake(…)(copyexpenses’mayTake, including theRefuseNotRoutableallowance).- Classify:
submission := known && from.Semantic == posting.Draft && to.Semantic != posting.Draft && !reversing(to);payout := known && to.Semantic == posting.Payout;acquittal := kind == Kind && known && from.Semantic == posting.Acknowledged && inst.PayoutOpen && !inst.AccrualOpen && (to.Semantic == posting.Accrual || to.Semantic == posting.Cancelled). - When
examine:submission && kind == Kind→CheckSubmission(a).payout && kind == KindTopup→ds := workflow.Decisions(inst.ID);!OverspendApproved(ds, draftKeyOf(def), a.ClaimantID)→ErrOverspendNotApproved.payout && kind == KindRefund→ nothing (no channel; Ruling A19).payout && (kind == Kind || kind == KindTopup)→s, err := settle(ctx, tx, c, settleInput{Advance: a, Kind: kind, InstanceID: inst.ID, Amount: inst.Amount, …}).acquittal→q := lockAcquittal(org, a.ID)(ErrNoAcquittal);dec := DecideAcquittal(q.Facts(a.BaseAmount, today));(to.Semantic == posting.Accrual) == (dec.Outcome == OutcomeReturned)→ErrAcquittalBranch; for a non-zero acquittal re-resolve the account (expenses.GetCategory+UsableAccount, elseErrCategoryUnusable), and check the tax record is complete —taxrecord.Complete(q.TaxRecord)andtaxrecord.CheckEmployeeNamedagainst the category as it stands now (Ruling A30; the refusal is returned as*taxrecord.FieldError);UPDATE advance_acquittal SET acquitted_at = $3, acquitted_by = $4, expense_account_id = $5, updated_at = $3 WHERE org_id = $1 AND advance_id = $2(stillreceived, so the trigger allows it).
d := workflow.Transition(…{InstanceID: inst.ID, From, To, Actor, Reason}).- Stale guard (Plan 5’s 5b):
!examine && (submission || payout || acquittal)→workflow.ErrStaleState. - Leg (A9, A21): if
acquittal && to.Semantic == posting.Accrual && dec.Outcome ∈ {Topup, Refund}→leg := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: KindTopup|KindRefund, SourceID: a.ID, Reporter: a.ClaimantID, Amount: dec.LegAmount});UPDATE advance SET topup_instance_id|refund_instance_id = $3 WHERE org_id = $1 AND id = $2(the trigger checks the match). a2 := GetAdvance;audit.Write{EntityKind: EntityAdvance, EntityID: a.ID, Action: "transition", ActorID: &in.Actor, Before: {kind, state}, After: {kind, state, decision_id, settlement_id, leg_instance_id}};activity.Recordwith verbsubmitted(leaving the advance’s draft),paid(entering any payout),acquitted(entering the accrual),returned(enteringreturned), elsetransitioned.
available.go: workflow.AvailableTransitions for the chosen instance; if kind == Kind and the instance is at an acknowledged state with the payout open and no accrual, look up the acquittal (absent ⇒ both acquittal exits get the refusal) and the outcome, and for each entry whose destination is an accrual or cancelled state set Allowed = false, Refusal = &workflow.Refusal{Code: RefuseAcquittalBranch} when it does not match. The claimant’s entries whose Allowed is already false keep the engine’s refusal.
-
Step 4: Run.
go test ./internal/platform/advances/... -count=1→ PASS. -
Step 5: Sabotage. (a) Remove the
OverspendApprovedcheck and publish, in the test’s org, a v2advance_topupgraph whosedraft → topup_paidis a target edge (OWNER seat) — a top-up started after that publication pays with no sign-off: the check’s absence is visible only there, so addTestAnOrgGraphWithoutSignOffCannotPayAnOverspend(publish viaworkflow.PublishGraph; start a fresh advance’s leg; pay →ErrOverspendNotApproved; PRESENCE: the seeded version pays after approval), watch it red with the check removed, quote, restore. (b) Swap the branch condition’s equality →TestTheAcquittalPicksTheBranchred; quote; restore. -
Step 6: Commit —
git commit -m "Move advances through their workflows: branch by variance sign, start the leg, require sign-off on excess".
Task 9: Reseal advance settlements after a payout-key rotation
Section titled “Task 9: Reseal advance settlements after a payout-key rotation”Waits for Task 1. Needs Tasks 4, 7, 8 (its fixtures pay advances through advancestest.Received). Runs in parallel with Task 10 (disjoint files).
Files:
- Create:
internal/platform/advances/reseal.go,cmd/migrate/reseal_advances_test.go - Modify:
cmd/migrate/reseal.go(insert the advance lines; rewrite none of Plan 5’s),docs/DEPLOY_NOTES.md(a Plan 7 section on top) - Test:
internal/platform/advances/reseal_test.go
Interfaces:
-
Consumes:
payout.ResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error),payout.SettlementSnapshot,payout.ErrUnknownKey;payouttest.NewKey,payouttest.UseKeyring(tests); Task 4’s column grant andadvance_settlement_reseal_only;cmd/migrate’srunReseal,orgReseal,resealReport(Plan 5 Task 21). -
Produces:
package advances// ResealSettlements re-encrypts, under activeKeyID, up to limit of the// calling org's advance settlement snapshots holding a value sealed under// another key. Rows locked in id order by one SELECT … FOR UPDATE before// the first audit.Write; a row is updated and audited (EntitySettlement,// "reseal", key ids only) only when payout.ResealSnapshot reports a change.func ResealSettlements(ctx context.Context, tx pgx.Tx, c clock.Clock, activeKeyID string, limit int) (n int, more bool, err error)func PendingReseal(ctx context.Context, tx pgx.Tx, activeKeyID string) (int, error)package main // cmd/migrate/reseal.go — inserted// orgReseal gains: AdvanceSettlementsResealed, AdvanceSettlementsPending int// resealReport.Pending() adds AdvanceSettlementsPending. -
Step 1: Write the failing platform tests —
reseal_test.go(two advances walked withe.Received(t, "1000"), which pays throughbank_transferwith Staff’s profile, so two snapshots under"test-1"; the no-op poster):
func TestResealMovesEveryAdvanceSnapshotToTheActiveKey(t *testing.T) { // k2 := payouttest.NewKey(t, "test-2"); payouttest.UseKeyring(t, k2, &e.PayoutKey). // PRESENCE: PendingReseal("test-2") == 2. ResealSettlements(…, 100) -> 2, // false; PendingReseal == 0; every sealed kid "test-2"; View() unchanged; // amount, credit_account_id, kind, txid unchanged; two audit rows // (entity "advance_settlement", action "reseal", key ids only; no "6789", // "ct", "nonce" or payee name in their JSON).}func TestAdvanceResealIsIdempotentAndResumable(t *testing.T) { // limit 1: 1,true; 1,true; 0,false — exactly two audit rows; a second full // run writes nothing more.}func TestAdvanceResealTouchesOnlyTheCallingOrg(t *testing.T) // e.Another(t) with its own paid advance keeps PendingReseal 1- Step 2: Run and watch fail. Step 3: Implement
reseal.go— exactlyinternal/platform/expenses/reseal.go’sResealSettlements,PendingResealandsealedKeyIDswithexpense_claim_settlementreplaced byadvance_settlement, the error prefixadvances:andEntitySettlement. The pending predicate reads only plaintextkidmembers:
const pendingPredicate = `jsonb_path_exists(s.snapshot, '$.fields[*].sealed.kid ? (@ != $kid)', jsonb_build_object('kid', $2::text))`-
Step 4: Run. PASS. Sabotage: drop the
if !changed { continue }guard and the pending predicate → the idempotency test red; quote; restore. Commit —git commit -m "Reseal advance settlement snapshots under the active payout key". -
Step 5: Write the failing command test —
cmd/migrate/reseal_advances_test.go(e := advancestest.New(t),e2 := e.Another(t),e.NopPoster(t), oneReceivedadvance in each org;app, _ := db.Open(ctx, e.H.AppDSN, eventbus.New())):
func TestResealCommandCoversAdvanceSettlements(t *testing.T) { // Rotate to "test-2". PRESENCE: runReseal(check=true) reports, per org, // AdvanceSettlementsPending 1 alongside Plan 5's counts, and Pending() // includes them. runReseal(check=false, batch=1): every pending count 0; // under k2 alone the advance snapshot still opens with payout.OpenSnapshot // (test-only, cmd/migrate is outside the A18 guard's directories).}-
Step 6: Insert into
cmd/migrate/reseal.go. InorgReseal, add the two fields; inPending(), add+ o.AdvanceSettlementsPending; inrunReseal, directly after Plan 5’s settlements loop, a second loop of the same shape callingadvances.ResealSettlements(ctx, tx, c, activeKeyID, batch)accumulating intor.AdvanceSettlementsResealed(error wrapped"reseal-payout-details: org %s: advance settlements: %w"), and inside the pending transactionr.AdvanceSettlementsPending, err = advances.PendingReseal(ctx, tx, activeKeyID); in the per-org print line, append, advance settlements resealed M (pending Q). Importinternal/platform/advances. Touch nothing else. -
Step 7: Deploy note — a new top section in
docs/DEPLOY_NOTES.md:
## Plan 7 — advances
- **Existing organizations get the three advance workflows by re-running provisioning** (`POST /v1/org/provision`, or `orgadmin provision`): step 60 publishes `advance`, `advance_topup` and `advance_refund` at v1 for any organization that has no version of them. New organizations get them at creation. No new environment variable.- **Key rotation:** `migrate reseal-payout-details` now also reseals advance settlement snapshots (`advance_settlement`), and `--check` exits 0 only when those are done too. The procedure in Plan 5's section is unchanged; the claims-table check has an advance twin: `SELECT count(*) FROM advance_settlement WHERE jsonb_path_exists(snapshot, '$.fields[*].sealed.kid ? (@ == "<old kid>")');` → `0`.- **Acquittal reminders are not sent.** `GET /v1/advances/reminders` lists overdue acquittals and who should be told; nothing emails or notifies anyone yet (Plan 7 Ruling A28).- Step 8: Run and commit.
go test ./cmd/migrate/ ./internal/platform/advances/ -count=1→ PASS.git add internal/platform/advances/reseal*.go cmd/migrate/reseal.go cmd/migrate/reseal_advances_test.go docs/DEPLOY_NOTES.md && git commit -m "Reseal advance settlements in migrate reseal-payout-details".
Task 10: Posting rules — (advance, payout) and (advance, accrual)
Section titled “Task 10: Posting rules — (advance, payout) and (advance, accrual)”Waits for Task 1. Needs Task 8.
Files:
- Create:
internal/platform/advances/posting.go(Rules, shared helpers),internal/platform/advances/posting_advance.go - Modify:
internal/platform/advances/advancestest/env.go(addUseAdvancePoster) - Test:
internal/platform/advances/posting_advance_test.go
Interfaces:
-
Consumes:
accounting.PostingRule,accounting.Source,accounting.Draft;ledger.Unreversed,ledger.Get,ledger.Line,ledger.SemanticPayout,ledger.Post(test),ledger.List(test);chart.ResolveControl,chart.ListJournals,chart.JournalBank,chart.JournalPurchase;GetAdvance,GetAcquittal,SettlementInThisTx;payout.SettlementSnapshot,payout.BehaviourPettyCash;workflow.Get,workflow.Decisions;documents.Get. -
Produces:
// Rules is every posting rule this package owns; modules/advances// registers them at boot. Task 11 appends TopupPayoutRule{}, RefundPayoutRule{}.func Rules() []accounting.PostingRuletype AdvancePayoutRule struct{} // (advance, payout): Dr Employee Advances / Cr the settled channel's accounttype AdvanceAccrualRule struct{} // (advance, accrual): Dr the acquittal's expense account / Cr the payout's control, at actual// Each: Kind() string; Semantic() posting.Semantic; Build(ctx, tx, src accounting.Source) (accounting.Draft, error)var ErrInstanceMismatch, ErrSettlementMismatch, ErrNotPaidOut, ErrNotAcquitted, ErrNothingToAccrue, ErrPettyCashDeferred error// payoutAccounts reads the advance's one unreversed payout entry: the// account it debited (the control, Ruling A20) and the account it// credited (the bank, Ruling A19). ErrNotPaidOut when there is none.func payoutAccounts(ctx context.Context, tx pgx.Tx, a Advance) (control, bank uuid.UUID, err error)// journalFor is the active journal whose default account is accountID,// else the default journal of fallbackType (Plan 5 R26's choice).func journalFor(ctx context.Context, tx pgx.Tx, accountID uuid.UUID, fallbackType string) (*uuid.UUID, string, error)package advancestest // additionfunc (e *Env) UseAdvancePoster(t *testing.T) // e.Env.UsePoster(t, advances.Rules()...) -
Step 1: Write the failing tests (
e.UseAdvancePoster(t)right afterNew):
func TestPayingOutAnAdvanceDebitsTheControl(t *testing.T) { // e.Received(t, "1000"): one journal_entry for the advance instance with // semantic payout, source_kind "advance", source_id advance.ID: Dr 1550 // 1000.0000 (party = Staff's party, memo "AD-2026-0001 advance") / Cr // e.CreditAccount(bank_transfer) 1000.0000; journal BNK (the journal // fronting 1520), else the default bank journal. Nothing posted at // submission, review, approval or acknowledgement. // e.ControlBalance(advance) == 1000.0000.}func TestAnAdvancePayoutReadsOnlyItsOwnSettlement(t *testing.T) // Build with a payout Source in a tx that wrote no settlement -> ErrNoSettlementfunc TestAnAdvancePayoutMustEqualTheAdvance(t *testing.T) { // PRESENCE: Build after settle(amount 1000) returns a Draft. With a // settlement of 999 inserted directly -> ErrSettlementMismatch.}func TestAPettyCashSnapshotIsNeverPostedForAnAdvance(t *testing.T) { // Ruling A18, the second refusal. In ONE tx, insert a settlement row // directly (erp_app) whose hand-built version-1 snapshot says // "settlement_behaviour":"external" -> Build returns a Draft (PRESENCE); // rolled back; the same with "petty_cash" and the petty_cash channel's // credit account -> ErrPettyCashDeferred, no Draft.}func TestAcquittalAccruesTheActualAgainstTheControl(t *testing.T) { // e.Record(t, a, "1200"); received -> acquitted (Owner): one entry, // semantic accrual, source_kind "advance": Dr <meals' account> 1200.0000 // (party Staff, memo "AD-2026-0001 acquittal 2026-09-20") / Cr 1550 // 1200.0000; journal PUR. ControlBalance == -200.0000.}func TestTheAccrualFollowsThePayoutsControlAccount(t *testing.T) { // Ruling A20. After the payout, retire 1550 and create a new // employee_advances control (admin tx through chart): the acquittal // still credits 1550; 1550's balance for the advance is -200.0000 and // the new account's is 0.}func TestAZeroAccrualCouldNeverPost(t *testing.T) { // Ruling A4 as a fact of Plan 4's ledger: PRESENCE — ledger.Post of a // two-line accrual entry of 0.0001 posts; the same entry at 0 is refused // by ledger.Post (the line rule at ledger/post.go:234). This is why a // zero acquittal takes `returned`.}func TestReturningInFullReversesThePayout(t *testing.T) { // e.Record(t, a, "0"); received -> returned (Owner, reason): one reversal // entry mirroring the payout (Dr <bank_transfer's account> 1000.0000 / Cr // 1550 1000.0000), source_kind "advance", source_id advance.ID; // ControlBalance == 0; no accrual entry; no leg.}func TestAnUpheldDisputeBeforeAcquittalReversesThePayout(t *testing.T) { // received -> disputed (Staff) -> rejected (Owner, uphold): the payout is // mirrored (contract §1.1 PastPayout); ControlBalance 0. rejected -> draft // -> … -> paid_out again: a second payout posts (the first is reversed, // Plan 4 R8) and a second settlement row exists; ControlBalance 1000.0000.}func TestEveryAdvancePostingIsTraceable(t *testing.T) { // Spec 11A.7. From the payout entry: source_id -> the advance (its // reference and purpose); workflow.Decisions names Owner as the approver // of `approved` and the payer of `paid_out`; Settlements has the row the // payout was made against. From the accrual entry: the acquittal's // receipt document resolves through documents.Get (status verified, // sha256 recorded); the decision into `acquitted` names Owner.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
AdvancePayoutRule.Build:a := GetAdvance(src.Event.SourceID);a.InstanceID != src.Event.InstanceID→ErrInstanceMismatch;s := SettlementInThisTx(src.Event.InstanceID); unmarshals.Snapshotintopayout.SettlementSnapshot;Channel.SettlementBehaviour == payout.BehaviourPettyCash→ErrPettyCashDeferred(before anything else is read);inst := workflow.Get;s.Amount,a.BaseAmount,inst.AmountpairwiseCmpequal, elseErrSettlementMismatch;control := chart.ResolveControl(ctx, tx, ControlKind)(a payout state names no control kind, sosrc.Controlis nil here);party := a.HolderPartyID;jid, jtype := journalFor(s.CreditAccountID, chart.JournalBank); lines{AccountID: control.ID, Debit: s.Amount, PartyID: &party, Memo: a.Reference + " advance"},{AccountID: s.CreditAccountID, Credit: s.Amount, PartyID: &party, Memo: …};Draft{JournalID: jid, JournalType: jtype, Currency: src.BaseCurrency, Description: "Advance " + a.Reference + " " + a.Purpose}.AdvanceAccrualRule.Build: advance and instance match;q := GetAcquittal(a.ID);q.AcquittedAt == nil→ErrNotAcquitted(Task 8 froze it earlier in this transaction);!q.ActualBase.IsPositive() || q.ExpenseAccountID == nil→ErrNothingToAccrue;control, _ := payoutAccounts(a); lines Dr*q.ExpenseAccountIDq.ActualBase(memoa.Reference + " acquittal " + receipt date), Crcontrolq.ActualBase, both with the holder’s party;JournalType: chart.JournalPurchase;Description: "Acquittal of advance " + a.Reference.payoutAccounts:ledger.Unreversed(a.InstanceID); exactly one header withSemantic == ledger.SemanticPayout, elseErrNotPaidOut;ledger.Getfor its lines; the one line withDebit.IsPositive()is the control, the one withCredit.IsPositive()the bank.journalFor:chart.ListJournals; the first active journal withDefaultAccountID != nil && *DefaultAccountID == accountID→(&j.ID, ""); else(nil, fallbackType).Rules()returns[]accounting.PostingRule{AdvancePayoutRule{}, AdvanceAccrualRule{}}.
-
Step 4: Run. PASS. Sabotage: make the accrual credit
src.Control.IDinstead ofpayoutAccounts’ control →TestTheAccrualFollowsThePayoutsControlAccountred; quote; restore. -
Step 5: Commit —
git commit -m "Post advance payouts and acquittals through the engine, traceable to receipt and approver".
Task 11: Posting rules — (advance_topup, payout) and (advance_refund, payout)
Section titled “Task 11: Posting rules — (advance_topup, payout) and (advance_refund, payout)”Waits for Task 1. Needs Task 10. Runs in parallel with Task 12 (disjoint files).
Files:
- Create:
internal/platform/advances/posting_variance.go - Modify:
internal/platform/advances/posting.go(append the two rules toRules()) - Test:
internal/platform/advances/posting_variance_test.go
Interfaces:
-
Consumes:
payoutAccounts,journalFor(10);GetAdvance,GetAcquittal,SettlementInThisTx;workflow.Get. -
Produces:
type TopupPayoutRule struct{} // (advance_topup, payout): Dr the payout's control / Cr the top-up's settled channel account, at +variancetype RefundPayoutRule struct{} // (advance_refund, payout): Dr the account the payout credited / Cr the payout's control, at -variance// Rules() now returns the four rules. -
Step 1: Write the failing tests (
e.UseAdvancePoster(t)):
func TestAnOverspendNetsTheControlToZero(t *testing.T) { // Spec 7.4 exactly: Received 1000 -> ControlBalance 1000.0000; Record // 1200 and acquit -> -200.0000; the leg: submit (Staff), approve_excess // (Owner), pay (Owner, bank_transfer) -> an entry, semantic payout, // source_kind "advance_topup", source_id = the ADVANCE id: Dr 1550 // 200.0000 / Cr <bank_transfer's account> 200.0000 -> ControlBalance // 0.0000. acknowledge (Staff) posts nothing.}func TestAnUnderspendRefundNetsTheControlToZero(t *testing.T) { // Received 1000; Record 700 and acquit -> ControlBalance 300.0000; the // leg: declare (Staff), confirm (Owner) -> an entry, source_kind // "advance_refund", source_id the advance id: Dr <the account the // payout credited> 300.0000 / Cr 1550 300.0000, journal BNK -> // ControlBalance 0.0000.}func TestATopupPayoutMustEqualTheVariance(t *testing.T) { // PRESENCE: Build after the real settlement returns a Draft. A settlement // row of 150 inserted directly in the paying tx -> ErrSettlementMismatch.}func TestATopupRefusesAPettyCashSnapshot(t *testing.T) // as Task 10's twin -> ErrPettyCashDeferredfunc TestTheRefundReturnsToTheAccountThePayoutLeft(t *testing.T) { // Pay the advance through a second seeded external channel crediting a // different bank account (e.SeedChannel with a second bank journal's // account): the refund debits THAT account (Ruling A19).}func TestAnUpheldTopupDisputeReversesOnlyTheTopup(t *testing.T) { // topup_paid -> disputed (Staff) -> rejected (Owner): one reversal of the // top-up entry only; the advance's payout and accrual stay; ControlBalance // back to -200.0000. rejected -> draft -> submit -> approve -> pay again: // ControlBalance 0.0000 (the first top-up is reversed, Plan 4 R8).}func TestEveryVariancePostingIsTraceable(t *testing.T) { // Top-up entry: source_id -> the advance -> its acquittal's verified // receipt; the leg's decisions name Owner for approve_excess (the // sign-off, wave-3 §5) and pay. Refund entry: the acquittal (receipt and // actual 700) and the decision into refund_confirmed naming Owner.}func TestEveryAdvancePostingCarriesTheAdvanceAsSource(t *testing.T) { // Over the overspend and underspend stories: every journal_entry whose // instance is one of the advance's instances has source_id = advance id // and source_kind in advances.SourceKinds (contract §2.1).}-
Step 2: Run and watch fail. Step 3: Implement.
TopupPayoutRule.Build:a := GetAdvance(src.Event.SourceID);a.TopupInstanceID == nil || *a.TopupInstanceID != src.Event.InstanceID→ErrInstanceMismatch;s := SettlementInThisTx(src.Event.InstanceID); petty-cash snapshot →ErrPettyCashDeferred;q := GetAcquittal;v := q.ActualBase.Sub(a.BaseAmount);v,s.Amount,inst.Amountequal, elseErrSettlementMismatch;control, _ := payoutAccounts(a); Drcontrol/ Crs.CreditAccountID, holder’s party;journalFor(s.CreditAccountID, chart.JournalBank);Description: "Top-up of advance " + a.Reference.RefundPayoutRule.Build: refund-instance match;q;r := a.BaseAmount.Sub(q.ActualBase);requalsinst.Amountand is positive, elseErrSettlementMismatch;control, bank := payoutAccounts(a); Drbank/ Crcontrol;journalFor(bank, chart.JournalBank);Description: "Refund of advance " + a.Reference.
-
Step 4: Run. PASS. Sabotage: make
RefundPayoutRulecreditbankand debitcontrol→TestAnUnderspendRefundNetsTheControlToZerored (600.0000); quote; restore. -
Step 5: Commit —
git commit -m "Post advance top-ups and refunds so the control nets to zero".
Task 12: ListForAging for Plan 9, and the reminder computation
Section titled “Task 12: ListForAging for Plan 9, and the reminder computation”Waits for Task 1. Needs Task 10 (payout entries date the issue). Runs in parallel with Task 11.
Files:
- Create:
internal/platform/advances/aging.go,internal/platform/advances/reminders.go - Test:
internal/platform/advances/aging_test.go,internal/platform/advances/reminders_test.go
Interfaces:
-
Consumes:
orgstruct.SuperiorOf(ctx, tx, memberID, on) ([]uuid.UUID, error); tablesadvance,party,journal_entry,workflow_instance(payout_open,accrual_open),workflow_state. -
Produces (Plan 9 D1’s names and fields, verbatim):
// AgingRef is what an aging report needs about an advance besides money.type AgingRef struct {ID uuid.UUIDReference string // "AD-2026-0007"HolderPartyID uuid.UUIDHolderName string // record-language legal nameIssuedOn time.Time // the payout's entry dateDueOn time.Time // the acquittal due date (reminders use the same date)}// ListForAging returns every advance issued on or before asOf, in the calling org (Ruling A27).func ListForAging(ctx context.Context, tx pgx.Tx, asOf time.Time) ([]AgingRef, error)const (LevelClaimant = "claimant"LevelManager = "manager"ManagerEscalationAfterDays = 7)type Reminder struct {AdvanceID uuid.UUIDReference stringClaimantMemberID uuid.UUIDDueOn time.TimeDaysOverdue intLevel string // LevelClaimant or LevelManagerRecipients []uuid.UUID // the claimant; plus their superiors at manager levelManagerUnresolved bool // manager level, and SuperiorOf found nobody}// EscalationFor is pure: days past due at asOf and the level (Level "" when not overdue).func EscalationFor(dueOn, asOf time.Time) (days int, level string)// Overdue lists unacquitted advances past due at asOf (Ruling A28), oldest due first.func Overdue(ctx context.Context, tx pgx.Tx, asOf time.Time) ([]Reminder, error) -
Step 1: Write the failing tests (
e.UseAdvancePoster(t); the fixture clock is 2026-09-21):
aging_test.go:
func TestAgingRefHasPlan9sShape(t *testing.T) { // reflect over AgingRef: exactly the fields ID uuid.UUID, Reference // string, HolderPartyID uuid.UUID, HolderName string, IssuedOn time.Time, // DueOn time.Time, in that order (Plan 9 D1).}func TestListForAgingListsIssuedAdvances(t *testing.T) { // PRESENCE: a Received advance (paid 2026-09-21, due 2026-10-15) is // listed with Reference, HolderPartyID, HolderName = Staff's legal name, // IssuedOn 2026-09-21, DueOn 2026-10-15. A draft advance and an approved // but unpaid one are not. asOf 2026-09-20 -> empty.}func TestListForAgingKeepsTheFirstIssueDate(t *testing.T) { // Pay on 2026-09-21; dispute upheld (reversal) and re-paid on a clock of // 2026-09-25: IssuedOn stays 2026-09-21; still listed (Plan 9 drops zero // balances itself).}func TestListForAgingIsOrgScoped(t *testing.T) // e.Another's paid advance is not in e's list; PRESENCE: it is in its ownreminders_test.go:
func TestEscalationFor(t *testing.T) { // due 2026-09-21: asOf 09-21 -> 0,""; 09-22 -> 1,claimant; 09-28 -> 7,claimant; // 09-29 -> 8,manager; 2027-01-01 -> 102,manager.}func TestOverdueListsUnacquittedAdvancesPastDue(t *testing.T) { // A Received advance due 2026-10-01. Overdue(2026-10-01) empty (PRESENCE of // the row is the next line); Overdue(2026-10-02) -> one Reminder, // DaysOverdue 1, Level claimant, Recipients [Staff]; // Overdue(2026-10-09) -> Level manager, Recipients [Staff, Owner].}func TestSettledAdvancesAreNotOverdue(t *testing.T) { // PRESENCE: all four are overdue at received. Then one acquitted, one // returned, one rejected via an upheld dispute, and one never paid (draft // with a past due date via admin UPDATE): Overdue lists none of them; // a recorded-but-undecided acquittal still counts as overdue.}func TestAReminderWithNoManagerSaysSo(t *testing.T) { // A claimant seated on a root position (e.Member with nil reportsTo) at // manager level: Recipients [claimant], ManagerUnresolved true.}- Step 2: Run and watch fail. Step 3: Implement.
ListForAging:
SELECT a.id, a.reference, a.holder_party_id, p.legal_name, min(e.entry_date), a.due_on FROM advance a JOIN party p ON p.org_id = a.org_id AND p.id = a.holder_party_id JOIN journal_entry e ON e.org_id = a.org_id AND e.instance_id = a.instance_id AND e.semantic = 'payout' AND e.source_kind = 'advance' WHERE a.org_id = $1 AND p.org_id = $1 AND e.org_id = $1 AND e.entry_date <= $2 GROUP BY a.id, a.reference, a.holder_party_id, p.legal_name, a.due_on ORDER BY min(e.entry_date), a.referenceOverdue:
SELECT a.id, a.reference, a.claimant_member_id, a.due_on FROM advance a JOIN workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE a.org_id = $1 AND i.org_id = $1 AND s.org_id = $1 AND i.payout_open AND NOT i.accrual_open AND s.semantic NOT IN ('rejected', 'cancelled') AND a.due_on < $2 ORDER BY a.due_on, a.referencethen per row EscalationFor; at manager level orgstruct.SuperiorOf(ctx, tx, claimant, asOf), appended after the claimant (deduplicated), ManagerUnresolved when empty. EscalationFor: days := int(asOf.Sub(dueOn).Hours() / 24) on midnight-UTC dates; days <= 0 → (0, ""); days <= ManagerEscalationAfterDays → claimant; else manager.
-
Step 4: Run. PASS. Sabotage: remove
AND NOT i.accrual_open→TestSettledAdvancesAreNotOverduered (the acquitted one listed); quote; restore. -
Step 5: Commit —
git commit -m "Expose advances for aging and compute overdue acquittals with an escalation level".
Task 13: Reconciliation — advance_control and advance_closed_nets_zero
Section titled “Task 13: Reconciliation — advance_control and advance_closed_nets_zero”Waits for Task 1. Needs Tasks 10, 11.
Files:
- Create:
internal/platform/advances/reconcile.go - Test:
internal/platform/advances/reconcile_test.go
Interfaces:
-
Consumes:
reconcile.Check,reconcile.Finding{Check, Ref, Detail},reconcile.Register,reconcile.Default,reconcile.NewSet(whatever constructorreconcile.goexports for a test-built set — Plan 5’sChecks()pattern);tenancy.OrgFromContext;ledger.Post(tests); tables as below. -
Produces:
const (CheckControl = "advance_control"CheckClosedNetsZero = "advance_closed_nets_zero")func Checks() []reconcile.Check // registered from init() -
Step 1: Write the failing tests (
e.UseAdvancePoster(t); a helper builds the healthy book: one draft, one Received (paid), one overspend acquitted with its top-up approved but unpaid, one overspend fully topped up, one underspend fully refunded, one returned, one exact):
func TestTheAdvanceChecksPassOnAHealthyLedger(t *testing.T) { // reconcile.Default().Run over the healthy book lists advance_control and // advance_closed_nets_zero, both with no findings.}func TestAnAdvanceThatDisagreesWithTheLedgerIsNamed(t *testing.T) { // PRESENCE: passes. Admin UPDATE workflow_instance SET payout_open = false // on the Received advance's instance: advance_control reports one Finding, // Ref = the advance id, Detail naming expected 0.0000 and ledger 1000.0000.}func TestAControlLineFromAnotherSourceIsNamed(t *testing.T) { // PRESENCE: passes. ledger.Post (directly, in a test tx) an accrual entry // with SourceKind "expense_claim" and a real claim instance, crediting // 1550 50.00 against an expense account: advance_control reports a // Finding whose Ref is the entry id and whose Detail says the Employee // Advances control carries a line not posted by an advance.}func TestAClosedAdvanceThatDoesNotNetToZeroIsNamed(t *testing.T) { // PRESENCE: passes. ledger.Post an extra entry with SourceKind // "advance_topup", SourceID = the fully topped-up advance, its top-up // instance id, Dr 1550 50.00 / Cr bank: advance_closed_nets_zero reports // Ref = that advance with "50.0000"; advance_control reports it too.}func TestTheChecksSumAcrossTheThreeKinds(t *testing.T) { // The overspend-topped-up and underspend-refunded advances have lines of // two kinds each; both checks pass only because they sum over // SourceKinds (see the sabotage).}- Step 2: Run and watch fail. Step 3: Implement (read-only, no locks, explicit org predicates,
init()registration exactly asinternal/platform/expenses/reconcile.go).
advance_control — per advance:
WITH ledger AS ( SELECT e.source_id, sum(l.base_debit - l.base_credit) AS base FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account ac ON ac.org_id = l.org_id AND ac.id = l.account_id WHERE l.org_id = $1 AND e.org_id = $1 AND ac.org_id = $1 AND ac.control_kind = 'employee_advances' AND e.source_kind = ANY($2) GROUP BY e.source_id)SELECT a.id, a.base_amount::text, i.payout_open, i.accrual_open, coalesce(q.actual_base, 0)::text, coalesce(t.amount, 0)::text, coalesce(t.payout_open, false), coalesce(r.amount, 0)::text, coalesce(r.payout_open, false), coalesce(g.base, 0)::text FROM advance a JOIN workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id LEFT JOIN advance_acquittal q ON q.org_id = a.org_id AND q.advance_id = a.id AND q.org_id = $1 LEFT JOIN workflow_instance t ON t.org_id = a.org_id AND t.id = a.topup_instance_id AND t.org_id = $1 LEFT JOIN workflow_instance r ON r.org_id = a.org_id AND r.id = a.refund_instance_id AND r.org_id = $1 LEFT JOIN ledger g ON g.source_id = a.id WHERE a.org_id = $1 AND i.org_id = $1Expected in Go (money.Amount, Ruling A26): base·payout_open − actual·accrual_open + topup·topup_open − refund·refund_open; unequal → Finding{Check: CheckControl, Ref: advance id, Detail: "expected <e> on Employee Advances, ledger has <g>"}. Then the attribution query — every control line whose entry’s source_kind is not in SourceKinds, or whose source_id is no advance:
SELECT DISTINCT e.id, e.source_kind FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account ac ON ac.org_id = l.org_id AND ac.id = l.account_id WHERE l.org_id = $1 AND e.org_id = $1 AND ac.org_id = $1 AND ac.control_kind = 'employee_advances' AND (e.source_kind IS NULL OR NOT (e.source_kind = ANY($2)) OR NOT EXISTS (SELECT 1 FROM advance a WHERE a.org_id = $1 AND a.id = e.source_id))(each → Finding{Ref: entry id, Detail: "Employee Advances carries a line not posted by an advance (source_kind <k>)"}).
advance_closed_nets_zero — closed = the advance’s state is terminal and its leg, if any, is terminal:
SELECT a.id, coalesce(g.base, 0)::text FROM advance a JOIN workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key LEFT JOIN workflow_instance li ON li.org_id = a.org_id AND li.id = coalesce(a.topup_instance_id, a.refund_instance_id) AND li.org_id = $1 LEFT JOIN workflow_state ls ON ls.org_id = li.org_id AND ls.definition_id = li.definition_id AND ls.state_key = li.state_key AND ls.org_id = $1 LEFT JOIN ledger g ON g.source_id = a.id -- the same CTE as above WHERE a.org_id = $1 AND i.org_id = $1 AND s.org_id = $1 AND s.is_terminal AND (li.id IS NULL OR ls.is_terminal) AND coalesce(g.base, 0) <> 0(each → Finding{Check: CheckClosedNetsZero, Ref: advance id, Detail: "closed advance leaves <g> on Employee Advances"}).
-
Step 4: Run. PASS. Sabotage: replace
= ANY($2)with= 'advance'in the ledger CTE →TestTheChecksSumAcrossTheThreeKindsred; quote; restore. -
Step 5: Commit —
git commit -m "Reconcile Employee Advances per advance and prove closed advances net to zero".
Task 14: modules/advances — composition, permissions, rules at boot, the advance routes
Section titled “Task 14: modules/advances — composition, permissions, rules at boot, the advance routes”Waits for Task 1. Needs Tasks 5, 11, 13 (boot registers advances.Rules(), which must hold all four rules; the checks and binder register from the platform package this links).
Files:
- Create:
internal/modules/advances/permissions.go,routes.go,boot.go,http.go,errors.go,advance_handlers.go,view.go - Modify:
constants.yaml(append four permissions) →make generate;api/openapi.yaml(append paths and schemas) →make generate;internal/app/app.go,internal/app/routes_test.go(composedModules),cmd/api/main.go,cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go,cmd/migrate/wiring_test.go,cmd/orgadmin/wiring_test.go,cmd/worker/wiring_test.go - Test:
internal/modules/advances/routes_test.go,boot_test.go,advance_handlers_test.go
Interfaces:
- Consumes: Tasks 5–8, 12 (
CreateAdvance,GetAdvance,UpdateAdvance,ListAdvances,ToAct,CanRead,GetAcquittal,Settlements,Settlement.View,Available,DecideAcquittal/Acquittal.Facts,EscalationFor);workflow.Decisions,workflow.Get;accounting.RegisterPostingRule;member.RegisterPermission,member.Can;httpx.Protected; the helpers ofinternal/modules/expenses/http.go(copy:decodeStrict,parseID,parseDate,actor,ifMatch,setETag,firstErr— a module does not import another module). - Produces:
Routes in this task (underpackage advances // internal/modules/advancesconst (PermissionAdvanceCreate = "advances.advance.create"PermissionAdvanceRead = "advances.advance.read"PermissionAdvanceReadAll = platformadvances.ReadAllPermission // "advances.advance.read_all"PermissionAdvanceTransition = "advances.advance.transition")type Deps struct {DB *db.DBClock clock.Clock}func Register(g *gin.RouterGroup, deps Deps)
/v1):
| Method | Path | Permission |
|---|---|---|
| GET | /advances |
advances.advance.read (?scope=mine|to_act|all; all also needs read_all) |
| POST | /advances |
advances.advance.create |
| GET | /advances/:id |
advances.advance.read |
| PATCH | /advances/:id |
advances.advance.create (If-Match) |
The advance read shape — the body of GET /advances/:id and of every advance mutation’s response (Tasks 14, 15), with ETag: "<revision>":
{ "advance": { "id", "reference", "claimant_member_id", "holder_party_id", "purpose", "currency", "amount", "fx_rate", "fx_rate_source", "base_amount", "due_on", "payout_channel_id", "revision", "state", "semantic" }, "acquittal": { "actual_currency", "actual_amount", "actual_fx_rate", "actual_fx_rate_source", "actual_base", "receipt_date", "receipt_document_id", "category_id", "reason", "document_basis", "supplier_name", "supplier_tin", "einvoice_uuid", "relaxation_reason", "outcome", "variance", "leg_amount", "expense_account_id", "recorded_by", "acquitted_at", "acquitted_by" } | null, "legs": [ { "kind", "instance_id", "state", "semantic", "amount", "available_transitions": [ { "to", "action_key", "gate", "requires_reason", "allowed", "labels", "refusal" } ], "decisions": [ { "from", "to", "actor_member_id", "authority_member_id", "via", "reason", "decided_at" } ] } ], "settlements": [ { "id", "kind", "channel_id", "channel_key", "channel_name", "account_tail", "amount", "currency", "fields": [ { "key", "label", "display" } ], "proof_document_id", "settled_by", "settled_at" } ], "overdue": { "days_overdue", "level" } | null }legs[0] is always the advance’s own instance (kind: "advance"), then its leg if any. available_transitions come from advances.Available (Ruling A9’s acquittal_branch refusal included). acquittal.outcome/variance/leg_amount are DecideAcquittal over the stored row (null when it refuses). settlements are built from Settlement.View() only: no snapshot, value or sealed member anywhere, and the OpenAPI schema has additionalProperties: false. overdue is EscalationFor(due_on, today) when the advance’s payout is open and its accrual is not.
- Step 1: Write the failing tests.
routes_test.go:TestEveryRouteRequiresItsExactPermissionin the shape ofinternal/modules/expenses/routes_test.go— the table above, the four permission strings pinned, and gin’s table under/v1/advancesequal to it (Task 15 extends it).boot_test.go:TestLinkingTheModuleRegistersTheFourPostingRules(accounting.DefaultRules().Lookupfinds(advance, payout),(advance, accrual),(advance_topup, payout),(advance_refund, payout));TestLinkingTheModuleRegistersTheChecksAndTheBinder(reconcile.Default().Runon a fresh org listsadvance_controlandadvance_closed_nets_zero;documents.BoundElsewhereon a fresh document returns no error and a document bound to an advance names kindadvance);TestLinkingTheModuleSeedsTheThreeKinds(workflow.SeededGraphscontains them).advance_handlers_test.go(the env pattern ofinternal/modules/expenses/claim_handlers_test.go: a provisioned org viaadvancestest,httpx.NewRouter, this module alone, members with minted tokens — Staff (create, read, transition), Owner (all four), Outsider (read only)):
func TestAnAdvanceIsFiledEditedAndReadWithItsETag(t *testing.T) { // POST /v1/advances {purpose, currency "MYR", amount "1000.00", due_on} // -> 201, ETag "1", reference AD-2026-0001, legs[0].state "draft", the // submit transition allowed for Staff. PATCH with If-Match "1" -> 200, // ETag "2". PATCH with If-Match "1" -> 409 naming revision 2. Without // If-Match -> 400.}func TestMoneyIsAStringBothWays(t *testing.T) // "amount": 1000 (a JSON number) -> 400 naming "amount"; response amounts are stringsfunc TestAFieldRefusalNamesItsFieldAndRule(t *testing.T) // due_on yesterday -> 422 whose detail starts "field=due_on rule=past:"func TestAnAdvanceNobodyShouldSeeIsNotFound(t *testing.T) // PRESENCE Staff 200; Outsider 404; Outsider with read_all 200func TestScopeAllNeedsReadAll(t *testing.T) // ?scope=all as Staff -> 403; as Owner -> 200func TestTheQueueShowsWhatICanDecide(t *testing.T) // ?scope=to_act as Owner after Staff submits -> it; as Staff -> emptyfunc TestAPaidAdvanceShowsOnlyMaskedPayoutDetails(t *testing.T) { // advancestest Received (in-process): GET as Staff and as Owner — // settlements[0].account_tail "6789", account_no display "****6789"; // the raw body contains neither the full number nor "sealed" nor // "snapshot" nor "value".}func TestTheReadShapeShowsTheLegAndTheOverdueBlock(t *testing.T) { // A Received advance due 2026-09-20 read on 2026-09-21: overdue // {days_overdue 1, level "claimant"}. After recording 1200 and acquitting // (in-process): acquittal.outcome "topup", variance "200.0000"; legs[1] // kind "advance_topup", state "draft"; overdue null.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
permissions.go:init()registers the four with descriptions (advances.advance.transition: “Use the advance transition endpoint; authority to decide still comes from the workflow’s grants”).constants.yaml: append the four after the lastexpenses.*line.boot.go:init()—for _, r := range platformadvances.Rules() { accounting.RegisterPostingRule(r) }. The graphs, checks and binder register frominternal/platform/advances’s owninit(), which this import links.advance_handlers.go: each handler oneInTx;ifMatchon PATCH; reads build the shape inview.go; visibility after loading:CanRead(…, member.Can(ctx, tx, caller, PermissionAdvanceReadAll))false → 404.errors.gomapErr(extended by Task 15):ErrNotFound404;ErrNotClaimant403;*RevisionMismatchError409 (“the advance is at revision N; reload and retry”);ErrNotDraft409;*FieldError,*SubmissionError422 (Ruling R43 shape, problems joined with"; ");ErrBadCursor422; a Postgres refusal onadvance_is_draft→ 409 (the database said what Go should have).- Composition:
app.Deps.Advances advances.Deps;app.Routercallsadvances.Register(api, deps.Advances)afterexpenses.Register;composedModules["advances"]lists the four keys.cmd/api/main.go: import the module,Advances: advances.Deps{DB: database, Clock: clock.System()}.cmd/migrate,cmd/orgadmin,cmd/worker: blank-importinternal/modules/advances(permissions, rules, graphs, checks, binder); theirwiring_test.gogain the four permission keys (the worker must link the posting rules — Plan 4 R31 — or a transition it ever runs would failNoPostingRuleError).internal/app/binaries_test.gothen sees the module in all three ofcmd/{api,migrate,orgadmin}. api/openapi.yaml: append the four operations withAdvance,AdvanceInput,AdvancePatch,AdvanceView,AdvanceAcquittalView,AdvanceLeg,AdvanceSettlementView(additionalProperties: false),AdvanceOverdue; money fields$ref: '#/components/schemas/Money', ratesRate, the workflow shapes by$refto the existingAvailableTransitionandDecisionschemas if Plan 5 declared them (else declareAdvanceAvailable/AdvanceDecisionhere).
-
Step 4: Run.
make generate && go test ./internal/modules/advances/ ./internal/app/ ./cmd/... -count=1→ PASS. -
Step 5: Commit —
git commit -m "Compose the advances module: permissions, posting rules, advance routes".
Task 15: The acquittal, transition and reminder routes
Section titled “Task 15: The acquittal, transition and reminder routes”Waits for Task 1 (row 14). Needs Tasks 6, 8, 12, 14.
Files:
- Create:
internal/platform/advances/idempotency.go,internal/modules/advances/acquittal_handlers.go,transition_handlers.go,reminder_handlers.go - Modify:
internal/modules/advances/routes.go,routes_test.go,errors.go,api/openapi.yaml→make generate - Test:
internal/platform/advances/idempotency_test.go,internal/modules/advances/acquittal_handlers_test.go,transition_handlers_test.go,reminder_handlers_test.go
Interfaces:
- Consumes:
RecordAcquittal(6),Transition(8),Overdue(12), the read shape (14);ledger.MapError; the error types of Tasks 5–8, 10–11; Plan 6’s typed refusals asinternal/modules/expenses/transition_handlers.go’smapPayoutErrlists them. - Produces:
Routes (underpackage advances // platform, copied from internal/platform/expenses/idempotency.go onto advance_idempotency_keytype Replay struct{ Status int; Body json.RawMessage }func BeginIdempotent(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, key, requestHash string) (*Replay, error)func FinishIdempotent(ctx context.Context, tx pgx.Tx, member uuid.UUID, key string, status int, body []byte) errorvar ErrIdempotencyKeyReused error
/v1):
| Method | Path | Permission |
|---|---|---|
| PUT | /advances/:id/acquittal |
advances.advance.create (If-Match) |
| POST | /advances/:id/transitions |
advances.advance.transition (Idempotency-Key) |
| GET | /advances/reminders |
advances.advance.read_all |
PUT /advances/:id/acquittal body {"actual_currency"?, "actual_amount", "receipt_date"?, "receipt_document_id"?, "category_id"?, "reason"?, "document_basis"?, "supplier_name"?, "supplier_tin"?, "einvoice_uuid"?, "relaxation_reason"?} → 200, the read shape. POST /advances/:id/transitions body {"kind"?: "advance"|"advance_topup"|"advance_refund", "from", "to", "reason"?, "payout_channel_id"?, "proof_document_id"?} → 200, the read shape. GET /advances/reminders?as_of=YYYY-MM-DD (default today) → {"as_of", "reminders": [{"advance_id", "reference", "claimant_member_id", "due_on", "days_overdue", "level", "recipients", "manager_unresolved"}]}. GET /advances/reminders is registered before GET /advances/:id so gin does not read reminders as an id.
- Step 1: Write the failing tests.
idempotency_test.go — Plan 5’s four, on this table: TestAKeyReplaysItsStoredResponse, TestAKeyWithADifferentRequestIsRefused, TestAFailedRequestGivesItsKeyBack, TestConcurrentSameKeyRequestsSerialize (two goroutines, 10 s contexts, never both nil); plus TestAdvanceKeysAreSeparateFromClaimKeys (the same member and key string used on expenses.BeginIdempotent and advances.BeginIdempotent with different hashes: neither refuses — separate tables, Ruling A23).
acquittal_handlers_test.go:
func TestRecordingAnAcquittalOverHTTP(t *testing.T) { // A Received advance (in-process walk). PUT with If-Match and a verified // receipt -> 200, acquittal.outcome "topup", ETag moved. PUT without // If-Match -> 400; stale -> 409.}func TestAcquittalRefusalsMapToStableStatuses(t *testing.T) { // Accepted case first. actual "-1" -> 422 "field=actual_amount rule=negative:"; // 0 without reason -> 422 "field=reason rule=required:"; a receipt bound to a // claim -> 409 naming "expense_claim" and not "EC-2026-0001" (contract §8.3); // basis einvoice_company without uuid -> 422 "field=einvoice_uuid // rule=einvoice_identifiers:"; another member's // document -> 422 naming receipt_document_id; a draft advance -> 409 // ("the advance is not awaiting acquittal"); Owner recording -> 403.}transition_handlers_test.go (e.UseAdvancePoster(t) right after the fixture — expensestest.New reset the init-registered Poster):
func TestAnOverspendEndToEndOverHTTP(t *testing.T) { // Staff submits (key k1); Owner reviews, approves, pays (payout_channel_id // bank_transfer); Staff acknowledges; Staff PUTs the acquittal (1200); // Owner acquits; Staff submits the top-up (kind "advance_topup"); Owner // approves the excess and pays; Staff acknowledges. Every response is the // read shape in the new state; ControlBalance ends at 0.}func TestTheSameKeyReplaysAndDoesNotMoveTwice(t *testing.T) // replay k1 -> 200, same body, one submit decision; k1 with another body -> 422func TestMissingIdempotencyKeyIs400(t *testing.T)func TestTransitionRefusalsMapToStableStatuses(t *testing.T) { // Table, accepted case first where it applies: stale from -> 409; unknown // to -> 422; missing reason on reject -> 422; Staff approving -> 403 with // the workflow refusal code; stale due date at submission -> 422 naming // due_on; the petty_cash channel -> 422 naming payout_channel_id; each of // Plan 6's refusals mapPayoutErr lists -> 422; acquitting with no // acquittal -> 409 "no acquittal has been recorded"; acquitting a zero // acquittal -> 422 naming "acquittal_branch"; returning a 1200 acquittal // -> 422 "acquittal_branch"; a top-up leg on an advance without one -> // 404; paying a top-up without sign-off (the v2 graph of Task 8's // sabotage test) -> 409; a closed fiscal period at payout -> 409 naming // the period.}func TestACommitTimeLedgerErrorIsMapped(t *testing.T) { // Plan 5 Task 19's deferred-constraint trick, verbatim in shape: a test- // only deferred constraint trigger on journal_line raising 23514 with // CONSTRAINT 'analytic_distribution_required'. PRESENCE: without it, a // payout succeeds (another advance). With it: the payout returns 422 // mapped by ledger.MapError, not 500; the advance is still approved; no // journal_entry committed; no settlement row committed.}reminder_handlers_test.go:
func TestRemindersNeedReadAll(t *testing.T) // Staff -> 403; Owner -> 200func TestRemindersListOverdueAcquittals(t *testing.T) { // A Received advance due 2026-09-10: ?as_of=2026-09-12 -> level // "claimant", recipients [Staff]; ?as_of=2026-09-20 -> "manager", // recipients [Staff, Owner]; ?as_of=bad -> 400. PRESENCE for the // route-order rule: GET /v1/advances/reminders is not a 404 "advance not found".}-
Step 2: Run and watch fail. Step 3: Implement.
idempotency.go:internal/platform/expenses/idempotency.gowithexpense_idempotency_key→advance_idempotency_keyand theadvances:error prefix.acquittal_handlers.go:ifMatch; oneInTx→RecordAcquittal→ the read shape.transition_handlers.go: exactly Plan 5’s handler shape — parse id,decodeStrictbody,Idempotency-Key(400 without; the length limit ofadvance_idempotency_key_length),hash := sha256(method + path + canonical body);err := InTx(func(ctx, tx) { replay := BeginIdempotent(…); if replay != nil { remember; return nil }; a, _, err := platformadvances.Transition(…); body := view(a); FinishIdempotent(200, body) }); thenerr = ledger.MapError(err)beforemapErr(wave-2 §3).reminder_handlers.go: parseas_of(defaultclock.TodayUTC); one readInTx→Overdue.mapErradditions:workflow.ErrStaleState409;workflow.ErrNoSuchEdge,workflow.ErrReasonRequired422;*workflow.RefusedError403 with the refusal code;*workflow.SubmissionRefusedError422 naming each step;ErrNoSuchLeg404;ErrNoAcquittal409;ErrAcquittalBranch422 ("rule=acquittal_branch: the recorded acquittal calls for the other exit");ErrOverspendNotApproved409;ErrNotAcquittable409;*AcquittalError422 (its ownError()is the wire detail);*DocumentBoundElsewhereError409 (itsError()names the kinds only, contract §8.3);*taxrecord.FieldError422 in Plan 5 R43’sfield=<f> rule=<r>: <sentence>shape;ErrNotYourDocument,ErrDocumentNotVerified,ErrCategoryUnusable422 naming the field; Task 7’s channel errors 422 namingpayout_channel_id/proof_document_id;mapPayoutErr— the same five rows asinternal/modules/expenses/transition_handlers.go(copy them:payout.ErrChannelNotFound,ErrChannelInactive,ErrNoProfile,*payout.IneligibleError,*payout.InvalidDetailsError);payout.ErrNoKeyring,ErrUnknownKey,ErrSealBroken500 with the fixed operator sentence;*fiscal.PeriodNotOpenError409;*chart.NoControlAccountError409 (“accounting is not set up: no Employee Advances control account”);*accounting.NoPostingRuleError500;ledger.ErrUnbalanced500;ErrInstanceMismatch,ErrSettlementMismatch,ErrNotPaidOut,ErrNotAcquitted,ErrNothingToAccrue500 (rule invariants — a bug, not the caller’s);ErrPettyCashDeferred422 namingpayout_channel_id(unreachable throughsettle; mapped for safety);ErrIdempotencyKeyReused422; Postgres refusals onadvance_acquittal_while_received/advance_acquittal_is_final→ 409, onadvance_variance_leg_matches/_fixed→ 500 (Go decided the leg; the database disagreeing is a bug).api/openapi.yaml: the three operations;AcquittalInput(with the five tax-record fields anddocument_basisas an enum of the four bases),AdvanceTransition,AdvanceReminders,AdvanceReminder.
-
Step 4: Run.
make generate && go test ./internal/modules/advances/ ./internal/platform/advances/ ./internal/app/ -count=1→ PASS. Sabotage: remove theledger.MapErrorcall →TestACommitTimeLedgerErrorIsMappedred (500); quote; restore. -
Step 5: Commit —
git commit -m "Record acquittals, move advances and list overdue acquittals over HTTP".
Task 16: End to end, the roadmap, and the one full run
Section titled “Task 16: End to end, the roadmap, and the one full run”Waits for everything above.
Files:
-
Create:
internal/modules/advances/e2e_test.go -
Modify:
docs/ROADMAP.md -
Step 1: Write the end-to-end test — the plan in sequence, over HTTP where a route exists. The router composes
modules/advancesandmodules/expenses(forGET /v1/documents/:idand the upload routes) andmodules/accounting(forGET /v1/accounting/reconciliation);e.UseAdvancePoster(t)right after the fixture (a payout keyring"test-1"is installed by it, ascmd/apiinstalls one):
func TestAdvancesFromRequestToAcquittal(t *testing.T) { // 0. Provisioned MY org: GET /v1/org/readiness shows workflow_definitions // done with the three advance kinds published at v1. // 1. OVERSPEND (spec 7.4, exactly). Staff files AD-2026-0001 (MYR 1,000.00, // due 2026-10-15); Staff submits; Owner reviews, approves, pays through // bank_transfer: Dr 1550 1,000 / Cr 1520 1,000, the settlement stored // sealed (GET shows only "****" displays). Staff acknowledges. // Staff (holding documents.document.upload, seeded on filing roles, // contract §8.3) uploads a receipt (presign, MemStore upload, complete) // and PUTs the acquittal: 1,200.00, dated 2026-09-20, meals, basis // einvoice_company with supplier TIN and e-Invoice UUID (Ruling A30). // Owner opens the receipt through GET /v1/documents/{id} (200 — the // advance binder, not a claim, grants it; contract §1.4). A member // holding only advances.advance.read_all opens it too (A33); one with // no advances permission gets 404. Owner acquits: Dr <meals' account> // 1,200 / Cr 1550 1,200; the top-up leg is in draft for 200. // Staff submits the top-up; Owner approves the excess and pays it: // Dr 1550 200 / Cr 1520 200. Running control balance for the advance: // 1,000 -> -200 -> 0. Staff acknowledges; the advance is closed. // 2. UNDERSPEND. AD-2026-0002 (1,000) paid and acknowledged; acquittal // 700.00; acquitted: Dr expense 700 / Cr 1550 700; refund leg for 300; // Staff declares it, Owner confirms: Dr 1520 300 / Cr 1550 300. Control 0. // 3. NOTHING SPENT. AD-2026-0003 (1,000) paid and acknowledged; acquittal // 0 with reason "Trip cancelled"; acquitting it is refused (422 // acquittal_branch) and the read shape's `acquitted` exit says // allowed=false; Owner confirms the full return (received -> returned): // the payout is mirrored (Dr 1520 1,000 / Cr 1550 1,000); no accrual, // no leg. Control 0. // 4. OVERDUE. AD-2026-0004 paid, due 2026-09-25: GET /v1/advances/reminders // ?as_of=2026-10-05 lists only it, level "manager", recipients // [Staff, Owner]; the three closed advances are not listed. // 5. AGING. advances.ListForAging(as of 2026-10-05) lists all four with // IssuedOn 2026-09-21 and their due dates, and HolderName Staff's legal // name (Plan 9 D1's inputs). // 6. GET /v1/accounting/reconciliation passes, including advance_control // and advance_closed_nets_zero; the sum of every advance's control // balance equals 1550's balance (1,000: AD-2026-0004 is outstanding). // 7. Traceability (spec 11A.4): from every journal line on 1550, the entry's // source_id resolves to an advance; that advance's receipt (or reason), // its settlement, its tax record (basis, TIN, e-Invoice UUID — spec // 6.7), and the approving member of every step resolve; // audit.VerifyChain is intact. // 8. Key rotation: install "test-2" with "test-1" previous; // advances.ResealSettlements in the org's transaction; PendingReseal 0; // GET the paid advances: identical settlements view.}-
Step 2: Run it.
go test ./internal/modules/advances/ -run TestAdvancesFromRequestToAcquittal -count=1 -v→ PASS. -
Step 3: Tick the roadmap. Under Plan 7 — Advances add
Plan: [2026-09-22-plan-7-advances.md](superpowers/plans/2026-09-22-plan-7-advances.md) (16 tasks); tick “Acquittal decision table; signed variance fan-out;payout_firstordering” with the indented notes “Three workflow kinds (advance,advance_topup,advance_refund, contract §2.1, §8.1); an acquittal of zero returns the advance throughreturned(Ruling A4)” and “The acquittal carries the §6.7 tax record, validated bytaxrecord(Ruling A30)”. Leave “Acquittal reminders” unticked and add under it: “Computed, not delivered:GET /v1/advances/remindersand theoverdueblock give the escalation level (claimant, then manager, Ruling A28); delivery waits for River (Plan 9), a mailer, and tenant enumeration (contract §4).” Touch no other line. -
Step 4: The one full run.
make lint && go test -p 2 -count=1 ./...→ every package passes. A failure outside this plan’s packages is reported with its package and test name, not fixed by editing another plan’s code. -
Step 5: Commit —
git commit -m "Prove advances end to end: overspend, underspend and full return net the control to zero; tick Plan 7".
Parallel groups
Section titled “Parallel groups”Tasks in one wave touch disjoint files and may run in parallel; waves run in order. “Needs” names the tasks whose output a task consumes. B = waits for Task 1 (the build base confirmed). Errors live beside the functions that raise them, so no two tasks in a wave edit one file.
| Wave | Tasks | Gate | Needs | Why they are disjoint |
|---|---|---|---|---|
| 1 | 1 confirm base · 2 decision table (pure) | — | — | 1: this plan document only. 2: advances/{doc,decide}.go. |
| 2 | 3 seeded graphs | B | 2 | advances/graphs.go. |
| 3 | 4 migrations 00130–00131 + fixture | B | 3 | Migrations, retention_test.go, sqlc.yaml, generated models, advancestest/. Alone among migration tasks. |
| 4 | 5 header, visibility, submission | B | 4 | advances/{advance,visibility,cursor,submission}.go. |
| 5 | 6 acquittal + binder · 7 settlement | B | 6: 2, 5 · 7: 5 | 6: advances/{acquittal,binder}.go. 7: advances/{settlement,nosnapshotopen_test}.go. |
| 6 | 8 transition service | B | 5, 6, 7 | advances/{transition,overspend,available}.go, advancestest/env.go (walk helpers). |
| 7 | 9 reseal · 10 advance posting rules | B | 9: 4, 7, 8 · 10: 8 | 9: advances/reseal.go, cmd/migrate/{reseal.go,reseal_advances_test.go}, DEPLOY_NOTES.md. 10: advances/{posting,posting_advance}.go, advancestest/env.go (UseAdvancePoster — the only fixture edit in the wave). |
| 8 | 11 variance posting rules · 12 aging + reminders | B | 11: 10 · 12: 10 | 11: advances/posting_variance.go, posting.go (append). 12: advances/{aging,reminders}.go. |
| 9 | 13 reconciliation | B | 10, 11 | advances/reconcile.go. |
| 10 | 14 module composition + advance routes | B | 5, 11, 13 | Every shared file (app.go, cmd/*, constants.yaml, openapi.yaml); runs alone. |
| 11 | 15 acquittal, transition, reminder routes | B | 6, 8, 12, 14 | openapi.yaml + module + advances/idempotency.go; alone. |
| 12 | 16 end to end + roadmap + full run | all | 1–15 | Alone. |
Critical path: 2 → 3 → 4 → 5 → 7 → 8 → 10 → 11 → 13 → 14 → 15 → 16.
Notes for later plans
Section titled “Notes for later plans”Binding hand-offs recorded by this plan’s rulings. Each later plan’s author must honour them or amend the ruling with the user.
- Plan 9 (reports), D1 aged advances. Sum over
advances.SourceKinds(e.source_kind = ANY($n)), not= 'advance'— top-ups and refunds post under their own kinds withsource_id= the advance (contract §2.1, Ruling A2).advances.ListForAgingandAgingRefare exactly D1’s names and fields (Ruling A27);IssuedOnis the first payout entry’s date and reversed advances are returned too, so D1’sHAVING sum(...) <> 0is what drops them. The golden fixture D1 asks for isadvancestest.EnvplusReceived/Record(Task 8). An advance with a refused top-up (Ruling A32) is outstanding and ages. - Whoever delivers acquittal reminders (after Plan 9’s River).
advances.Overdue(ctx, tx, asOf)already returns the level and the recipients; a job needs only tenant enumeration (Plan 4 R14’s open decision) and a person-addressed channel. Tick the ROADMAP’s “Acquittal reminders” then. The seven-day manager threshold isManagerEscalationAfterDays(Ruling A28). - Paying an advance from the tin (contract §4), after Plans 7 and 8 have both merged. Plan 7 refuses a
petty_cashchannel twice:settle(ErrChannelPettyCash,internal/platform/advances/settlement.go) and both payout rules (ErrPettyCashDeferred,posting_advance.go,posting_variance.go). The follow-up replaces both, as Plan 8 does for claims: credit the paying holder’s float account, never the channel’sCreditAccountID, under the petty-cash lock order (document → float → instance, contract §5). - Whoever enables
advance_offset(Plan 5 R11, contract §2.1).internal/platform/advancesimportsinternal/platform/expenses(categories, record text — Ruling A17). An offset line inexpensesthat needs an advance must not importadvancesback: move the shared pieces down, or haveadvancesstop importingexpenses, first. - Any plan that adds a table storing sealed payout data inserts its reseal into
cmd/migrate/reseal.gobeside Plan 5’s and Plan 7’s loops (Plan 5 R45, Ruling A18). - Correcting an acquitted figure is deferred (controller ruling 2026-09-22, contract §8.1; Ruling A5). It needs a later adjustment document kind: the engine reverses every unreversed entry of an instance, so “reverse only the accrual” cannot be a dispute uphold on the advance; the adjustment must post its own entries under a kind of its own (and join
advances.SourceKinds, or be summed by the two checks) and must settle the variance leg’s entries too. - A refused overspend needs a write-off / adjustment document kind to close it (controller ruling 2026-09-22, contract §8.1; Ruling A32). Until then the excess stays owed to the claimant on Employee Advances, the advance stays open,
advance_controlexpects it, and it ages. The same adjustment kind can serve both notes.
Contract departures (accepted)
Section titled “Contract departures (accepted)”Found while writing this plan; each was worked around inside Plan 7’s ownership by the ruling named. The controller accepted all six on 2026-09-22 and contract §8.1 now states them — they are no longer open.
- The variance-leg graphs have no
draftstate (§2.1 table:topup_pending → …,refund_pending → …). The validator’s rule 1 refuses them (draft_count) andworkflow.Startcannot start them (errNoDraftSemantic); §1.2 does not relax rule 1 forcash_only. → Ruling A3: each leg starts in its owndraft; the claimant opens it in a later transaction. Pinned byTestTheContractsLiteralGraphsWouldBeRefused(a). - An acquittal of zero cannot enter the accrual state (§2.1:
acquittedaccrues “atactual”; §7.3: actual = 0 is a valid acquittal, “whole advance comes back”).ledger.Postrefuses a zero line and the Poster has no “post nothing” path, so the transition would abort. → Ruling A4: areturnedterminal (cancelledsemantic) whose engine reversal mirrors the payout; no refund leg for zero. Pinned byTestAZeroAccrualCouldNeverPostandTestReturningInFullReversesThePayout. - “Disputes reachable from every post-payout state, with the seeded dismiss/uphold pattern” cannot hold for the two terminal posting states. Dismissing back into
acquittedisaccrual_on_cycle; dismissing back intorefund_confirmedispayout_on_cycle. → Rulings A5, A6: separate dismiss targets (acquittal_confirmed,refund_settled). Pinned byTestTheContractsLiteralGraphsWouldBeRefused(b), (c). - Upholding a dispute after acquittal would misstate the ledger. The engine mirrors every unreversed entry of the advance instance — the payout too — and cannot reach the variance leg’s entries, so the control would not net to zero. → Ruling A5: no uphold edge from
acquittal_disputedin the seeded graph; acquittal correction deferred to a later adjustment kind (Notes for later plans). advance_refundhas no reversing state for an upheld dispute (§2.1 lists onlydisputed), while §2.1 says upholding reverses through §1.1. → Ruling A6:rejectedadded (andrefund_settled).- A refused overspend has no defined end. §2.1 gives
advance_topuparejectedstate but neither it nor the spec says what the ledger should then show: the acquittal already expensed the full actual. → Ruling A32:rejected → draftonly; the excess stays owed and the advance open; a write-off kind is a later plan’s (Notes for later plans). - Resolved by the base build, recorded for completeness: §1.4’s “document lock taken first” contradicted Plan 5’s claim-then-document order, and
Binder.MayReadhad no clock — both fixed by contract §7 and followed here (Rulings A15, A33).
Self-Review Notes
Section titled “Self-Review Notes”Spec coverage.
- §7.1 (receipt deferred to acquittal, never waived) → A8, Task 6 (receipt required for any amount spent, DB CHECK
advance_acquittal_receipt_required), Task 10 (the accrual reads only a decided acquittal). - §7.2 flow → Task 3 (three graphs; A3–A7), Task 8 (the service);
receivedvsacquittedas separate moves → A8; disputes from every post-payout state →TestEveryPostPayoutStateCanBeDisputed(with A5/A6’s documented limits). - §7.3 decision table → Task 2 (pure, table-tested), Task 4 (named CHECKs mirroring each row), Task 6 (applied at recording), Task 8 (re-applied at the decision, branch enforced; A9); overspend sign-off → A10, Task 8.
- §7.4 ledger → Tasks 10, 11 (the 1,000/1,200/200 story exactly), Task 13 (nets to zero), Task 16 step 1.
- §7.5
payout_first→ Task 3. §7.6 reminders → A28, Task 12, Task 15 route, ROADMAP left unticked (contract §4). - §5.5 control account on the accrual (
employee_advances) → Task 3. §5.7 validator → Task 3 viaRequireSeededGraphValidand the base’s drift test. §10.3 rules inside the transition’s transaction → Tasks 10–11 throughworkflow.Transition; reversal is the engine’s (A4, A5 lean on it). §10.6 “Advances control” → A26, Task 13. §11A.3 → purpose and reason CHECKs. §11A.4/§11A.7 → every posting-rule task has a traceability test; Task 16 step 7. §11A.2 → DELETE revoked on all record tables; settlements append-only. §20.2 step 7 (advance seeded and published at v1) → A29, step 60 via the registry; Task 4’s fixture test. - Contract §2.1: three kinds and
source_id→ A2;cash_only→ Task 3; ranges → Global Constraints;advances.*→ A25;/v1/advances/…→ Tasks 14–15; reconcile keys → A26; binder kindadvance→ Task 6; own idempotency table → A23; external channels only → A18; reseal command → Task 9;ListForAging/AgingRef,AD-YYYY-NNNN,due_on→ A27, A13, A12. §4 deferrals → A28, A31, Notes. §5 lock order, start-before-transition, traceability, presence-before-absence → Global Constraints, A21, Task 8’s audit-order test, sabotage steps. §6 migrations commute → Global Constraints. §7 amendments → A15, A33. - §6.7/§11A.6 tax record at acquittal → A30, Tasks 4 (mirrored CHECKs), 6 (shape via
taxrecord), 8 (completeness at the decision), 15, 16. - Not covered, by ruling: acquittal correction (A5), a refused overspend’s closure (A32), advance from the tin and
advance_offset(A18, A31), reminder delivery (A28).
Placeholder scan. No TBD/TODO. Tests are written as named functions with their assertions in comments (the house style of Plans 5 and 6); code blocks give every non-trivial implementation (the decision function, the three graphs, both migrations, the overspend check, every reconciliation and aging query). Where a step copies merged code (expenses’ settle, mayTake, idempotency.go, reseal.go, the module HTTP helpers, mapPayoutErr), it names the file.
Type consistency. Kind/KindTopup/KindRefund/SourceKinds/ControlKind/BinderKind/ReadAllPermission (Task 2) are the names used in Tasks 3–16. AcquittalFacts/AcquittalDecision/Outcome*/AcquittalError (2) are what Acquittal.Facts (6), Transition (8), Available (8) and the read shape (14) use. Advance fields (5) — InstanceID, TopupInstanceID, RefundInstanceID, BaseAmount, HolderPartyID, PayoutChannelID, Semantic — are what 6–13 read. settleInput/Settlement/SettlementInThisTx(instanceID) (7) are keyed by instance, which is what both payout rules (10, 11) pass. payoutAccounts/journalFor (10) are reused by 11. Rules() holds two rules after 10 and four after 11; Task 14’s boot test asserts four. ManagerEscalationAfterDays, EscalationFor, Overdue (12) are what 14 (overdue block) and 15 (route) call. DocumentBoundElsewhereError (6) is what 15 maps. workflow.Instance.PayoutOpen, posting.Event.PastPayout, EdgeSpec.ReversesAccrual, workflow.CashOnly, RegisterSeededGraph, RequireSeededGraphValid(t workflowtest.TB, kind), documents.MayRead(ctx, tx, c, …) are the base’s names as built (Reconciliation record).
Decisions taken by the controller (2026-09-22). Every question this plan raised is closed:
- The six contract departures (A3–A6, A32, and A4’s
returned) — accepted; contract §8.1. - Tax record at acquittal — captured, validated by
taxrecord(A30). - A refused overspend — stays owed and open; a write-off kind is a later plan’s (A32, Notes).
- Correcting an acquitted figure — deferred to a later adjustment kind (A5, Notes).
- Reminder threshold — seven days, as the named constant
ManagerEscalationAfterDays(A28). - Document routes — fixed by the base (contract §8.3); the advance binder honours
advances.advance.read_all(A33), and the bound-elsewhere 409 names only the kind (A15).
Reconciliation record
Section titled “Reconciliation record”Read on phase1-wave3 at 1d9ddab while this plan was written. Task 1 confirms it on the build base and appends “Confirmed on <sha>” or the changes.
| # | Symbol | As built | Evidence |
|---|---|---|---|
| 1 | posting.Event.PastPayout |
identical to contract §1.1; PostsToLedger = PastAccrual || PastPayout for Rejected/Cancelled |
internal/platform/posting/posting.go:53-75 |
| 2 | workflow_instance.payout_open |
BOOLEAN NOT NULL DEFAULT false; set entering payout, cleared (with accrual_open) entering rejected/cancelled |
db/migrations/00034_workflow_payout_open.sql; internal/platform/workflow/instance.go:348-368 |
| 3 | workflow.Instance.PayoutOpen |
bool, scanned by instanceSelect |
internal/platform/workflow/instance.go:61 |
| 4 | reversal flag | name unchanged, EdgeSpec.ReversesAccrual; rule 5 now starts from accrual and payout states |
internal/platform/workflow/validate.go:440-461 |
| 5 | workflow.CashOnly |
"cash_only"; rule 3(c) adds accrual_in_cash_only and returns before the payout-ordering checks; (a), (b) unchanged; named CHECK workflow_definition_posting_order_known |
model.go:51-55; validate.go:347-356; 00034 |
| 6 | rule 1 under cash_only |
unchanged: exactly one draft state; Start still requires one |
validate.go rule1; instance.go:171-179 |
| 7 | workflow.RegisterSeededGraph |
(kind string, build func(ownerSeat uuid.UUID) Graph); panics on bad/duplicate kind, expense_claim, nil build, or a built graph of another kind (it calls build(uuid.Nil)); SeededGraphs = expense_claim then sorted kinds |
internal/platform/workflow/seed.go:119-170 |
| 8 | workflowtest.RequireSeededGraphValid |
(t TB, kind string), TB = require.TestingT + Helper() |
internal/platform/workflow/workflowtest/fixture.go:147-169 |
| 9 | binder API | as contract §1.4 except MayRead(ctx, tx, c clock.Clock, documentID, memberID) (contract §7); plus UnregisterBinderForTest |
internal/platform/documents/binder.go |
| 10 | expenses binding | ErrDocumentBoundElsewhere, *DocumentBoundElsewhereError{Bindings} with Is; AttachDocument locks claim → document → BoundElsewhere; the expenses binder counts settlement proofs as bound and leaves read_all to its route |
internal/platform/expenses/attachment.go, binder.go |
| 11 | document route | uploader → expenses.claim.read_all → documents.MayRead(ctx, tx, h.deps.Clock, …) |
internal/modules/expenses/document_handlers.go:242-259 |
| 12 | workflow.Refusal |
has Code string (and Gaps) — unchanged from main |
internal/platform/workflow/authorize.go |
| 13 | reversal source | ReverseAll copies SourceKind/SourceID from the original — unchanged from main |
internal/platform/accounting/ledger/reverse.go:184 |
| — | account.owner_module |
exists (00054), frozen by account_immutable; not used by Plan 7 (1550 has none) |
db/migrations/00054_account_owner_module.sql |
| 14 | internal/platform/taxrecord |
not yet on phase1-wave3 at 24e0c7c (contract §8.1 names it); the names in Task 1 row 14 are expected, extracted from expenses’ normalizeTaxRecord and CheckBasisAgainstCategory — Task 1 rewrites Tasks 4, 6, 8, 15 to the package as built |
internal/platform/expenses/linerules.go:186-230, 314-321 (the source being extracted) |
| 15 | document routes | contract §8.3 (at 24e0c7c): upload needs documents.document.upload; GET authenticated-only; each binder honours its own read_all; bound-elsewhere 409 names only the kind — the route change itself lands on the base shortly |
docs/superpowers/specs/2026-09-22-phase1-wave3-contracts.md §8.3 on phase1-wave3 |
Plan 7 — Advances Implementation Plan
Section titled “Plan 7 — Advances 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: A member asks for a cash advance (purpose, amount, due date), it is reviewed, approved and paid out through an external payout channel against a sealed settlement snapshot — posting Dr Employee Advances / Cr bank before any expense is recognised (payout_first) — and later acquitted against the real receipt through spec §7.3’s pure decision table: the acquittal accrues the actual to the receipt’s expense account, and a signed variance starts an advance_topup (overspend, paid only after an approver signs off on the excess) or an advance_refund (underspend, confirmed when the cash is back), while an acquittal of zero returns the whole advance; every posting carries source_id = <advance id>, the Employee Advances control nets to zero on every closed advance and two reconciliation checks prove it, Plan 9 gets ListForAging, and overdue acquittals are computed as data with an escalation level (delivery deferred).
Architecture: One platform package and one module. internal/platform/advances owns everything with an advance rule in it: the three workflow graphs (registered for seeding from its own init() through the base branch’s workflow.RegisterSeededGraph), the header, the acquittal record and its receipt binding (a documents.Binder), the pure acquittal decision function, the transition service around workflow.Transition (branch by variance sign, start the variance leg in the acquittal transaction, refuse petty cash, require the overspend approval), sealed settlements, four posting rules, two reconciliation checks, ListForAging, the reminder computation, its own idempotency store, and the snapshot reseal. internal/modules/advances is the HTTP shell for /v1/advances/… and registers the posting rules at boot. The database enforces what must never drift: draft-only header edits, an acquittal written only while the advance sits at received and frozen once decided, §7.3’s decision table as named CHECKs, a variance leg that matches the sign of the variance, append-only sealed settlements.
Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL, sqlc models only) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money
Specs (read before any task):
docs/superpowers/specs/2026-09-22-phase1-wave3-contracts.md— BINDING. §1 (the base-branch seam this plan consumes), §2.1 (this plan’s shape), §3 (ownership and ranges), §4 (deferrals), §5 (lessons), §6 (merge order; migrations apply out of order and must commute), §7 (amendments from the base build), §8 (amendments from plan writing, accepted by the controller on 2026-09-22) — read the copy onphase1-wave3. Where this plan departs from the original §2.1 it says so in a Ruling marked deviation (accepted, contract §8.1) and in Contract departures (accepted).docs/superpowers/specs/2026-09-21-phase1-wave2-contracts.md§3 and2026-09-21-phase1-wave1-contracts.md§3, §5 — still binding.docs/superpowers/specs/2026-09-20-erp-architecture-design.md— §7 in full, §5.5, §5.7, §10.3, §10.6, §11A, §20.docs/superpowers/plans/2026-09-21-plan-5-expense-claims.md— the house pattern this plan copies (settlement, transition wrapper, idempotency, visibility, routes, reseal) and its merged code underinternal/platform/expensesandinternal/modules/expenses.- Plan 9’s Deferred until Plans 7/8, D1 (
git show plan-9-reports:docs/superpowers/plans/2026-09-21-plan-9-reports.md) — theAgingRefshape. - The base branch
phase1-wave3is built (at1d9ddab, including the contract’s amended §6 “migrations apply out of order” and §7 “amendments from the base build”). Every contract §1 symbol this plan uses was read on that branch while writing it; the Reconciliation record lists each with itsfile:line. This branch is reset ontophase1-wave3before building, and Task 1 is the gate that confirms nothing moved.
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Go floor is 1.26.0. CI derives its version from
go.mod; never restate one. - Ownership (wave-3 contract §3). Go code only under
internal/platform/advances/…andinternal/modules/advances/…. Never editinternal/platform/{posting,workflow,documents,expenses,payout,accounting}orinternal/modules/expenses, or any base-branch file. From other plans, import only:workflow(the engine API and the contract §1.2–1.3 additions),posting,accounting(PostingRule,Source,Draft,NewPoster,NewRuleSet,RegisterPostingRule),ledger,chart,fx,reconcile,documents(Get,Lock,StatusVerified,ErrNotFound, and the binder API:Binder,Binding,RegisterBinder,BoundElsewhere,MayRead),expenses(GetCategory,UsableAccount,CheckRecordText— Ruling A17),taxrecord(the §6.7 rules — Ruling A30),payout(GetChannel,Snapshot,SettlementSnapshot,Names,BehaviourPettyCash,ResealSnapshot, the typed refusals),orgstruct.SuperiorOf,numbering,company,member,activity,audit. Plan 7 never callspayout.OpenSnapshot(Ruling A18; a guard test enforces it). - Migrations
00130–00149only; this plan uses00130–00131. They must commute with every higher-numbered migration (contract §6: goose runsupwith allow-missing, so Plan 8’s00150+ may be applied before them and Plan 9’s00110+ after them):00130–00131create only Plan 7’s own tables, functions and triggers, and redefine nothing another range owns. Provisioning step orders110–119are reserved and unused (Ruling A29). Permissionsadvances.*only. OpenAPI paths/advances/…only (served under/v1). Workflow kindsadvance,advance_topup,advance_refundonly. Reconcile keysadvance_control,advance_closed_nets_zeroonly. Document binder kindadvanceonly. - Shared files are append-only (wave-1 §3, wave-3 §3):
constants.yamlpermissions list,api/openapi.yaml,sqlc.yaml(column overrides),internal/app/app.go,internal/app/routes_test.go(composedModules), the module imports incmd/api,cmd/migrate,cmd/orgadmin,cmd/workerand theirwiring_test.go,cmd/migrate/reseal.go(Task 9 inserts its advance lines; it rewrites none of Plan 5’s),internal/kernel/db/retention_test.golists,docs/DEPLOY_NOTES.md(a new section on top),docs/ROADMAP.md(tick only Plan 7’s lines). Generated files are regenerated withmake generate, never hand-merged. - Tenant isolation, both layers. Every new table:
org_id UUID NOT NULL,ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid. Every SQL statement namesorg_id = $nfor each org-scoped table it touches (join equalities count) and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Every FK between two org-scoped tables is composite(org_id, x) REFERENCES t (org_id, id)(TestEveryTenantForeignKeyIsCompositeOnOrgID). - Named constraints (wave-2 §3). Every
CHECK,UNIQUEandEXCLUDEis writtenCONSTRAINT <name> …; no name ends in_check,_keyor_excl(Plan 5’sconstraintnames_test.go). A trigger’s refusal names its constraint withUSING CONSTRAINT = '<name>'. Error mapping is keyed on these names. - Pinned functions (wave-2 §3). Every function or trigger function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table (public.advance).TestEveryFunctionPinsItsSearchPathenforces the first half. - No
SET CONSTRAINTSin non-test Go. - Every table is in exactly one list in
internal/kernel/db/retention_test.go.advanceandadvance_acquittalareamendable(DELETE revoked);advance_settlementisappendOnly(UPDATE revoked at table level,UPDATE (snapshot)granted for the reseal only);advance_idempotency_keyisoperational. - Lock order, one for the whole plan (wave-2 §3, wave-3 §5 and §7): idempotency-key row →
advancerow (FOR UPDATE) →advance_acquittalrow (FOR UPDATE) →documentrow (documents.Lock, binding path only; the consumer’s own record first, then the document, thenBoundElsewhere— contract §7) →workflow_instancerow (taken insideworkflow.SetAmount/workflow.Transition) →audit.Write. This plan adds no advisory-lock class. - Never write an audit row before posting (wave-2 §3).
workflow.Transitionruns before anyaudit.Writeof this plan’s in the same transaction;workflow.Start(which audits) runs afterworkflow.Transitionin the acquittal transaction and in an earlier transaction than the started instance’s firstTransition(wave-3 §5). - Commit-time errors (wave-2 §3). Every handler whose transaction can post passes
db.InTx’s error throughledger.MapErrorbefore mapping it to HTTP. - One org per transaction. Every platform function reads the org from
tenancy.OrgFromContext; nothing takes an org argument. - No nested
db.InTx. Platform functions take(ctx, tx pgx.Tx, …). Only handlers and the reseal command open transactions. - Money is
money.Amount(NUMERIC(19,4)); exchange rates aremoney.Rate(NUMERIC(19,10)). Neverfloat64; never compare amounts with==(useCmp). On the wire both are strings (the OpenAPIMoneyandRateschemas). - The business date is
clock.TodayUTC(c); everyDATEhandled in Go is midnight UTC. Nevertime.Now()in code under test. - Record-bearing free text (advance purpose, acquittal reason, supplier name, relaxation reason) is English or Bahasa Malaysia (§11A.3):
expenses.CheckRecordTextin Go and a namedCHECKusing!~ '[-ヿ㐀-䶿一-鿿가-]'(written out in full at each use;<cjk>below stands for it). - Every posting has a path back to a document and a decision (§11A.4, §11A.7, wave-3 §5). Every posting-rule test asserts it by walking from the journal entry’s
source_idto the advance, its document(s) and the deciding member inworkflow.Decisions. - Errors are RFC 7807. Handlers
c.Error(apperr.New(kind, detail))and return. Platform code returns sentinel or typed errors, never*apperr.Problem. A field refusal travels as a 422 whose detail isfield=<name> rule=<code>: <sentence>(Plan 5 R43’s shape). - Every route goes through
httpx.Protectedwith anadvances.*permission registered in the module’sinit(), and is documented inapi/openapi.yamlin the same commit (internal/app/routes_test.go). postingandaccounting.DefaultRules()are process-global. Tests that need a Poster callposting.ResetForTest()first and int.Cleanup(the fixture does), registeraccounting.NewPoster(clock, rs)over a freshaccounting.NewRuleSet()holdingadvances.Rules(), and never uset.Parallel().- Presence before absence (
AGENTS.md). Every refusal test shows the accepted case first; every guard is watched failing once (a Sabotage step) and its red output is quoted in the task report. - Concurrency tests bound every wait with
context.WithTimeoutand clean up int.Cleanup. - Tests: during development run only the packages you changed (
go test ./internal/platform/advances/... -count=1).make generateafter any migration, OpenAPI orconstants.yamlchange. One fullgo test -p 2 -count=1 ./...in Task 16 only. - Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”Decisions taken where the specs are silent, ambiguous, or cannot be built as written. Format: Ruling: <decision> — <why> — <cost if wrong>. Rulings marked deviation (accepted) depart from the original contract §2.1 or the spec; the controller accepted each on 2026-09-22 and contract §8.1 now states them. They are repeated under Contract departures (accepted) at the end.
- A1 Ruling: Packages are
internal/platform/advances(+advances/advancestest, a non-test fixture package likeexpensestest) and one moduleinternal/modules/advances— contract §2.1 and §3 fix these paths and Plan 9 D1 importsinternal/platform/advances— none. - A2 Ruling:
advances.Kind = "advance",KindTopup = "advance_topup",KindRefund = "advance_refund", andadvances.SourceKinds = []string{Kind, KindTopup, KindRefund}exactly as contract §2.1 exports it; every posting of all three kinds carriessource_id = <advance id>because each variance leg’s workflow instance is started withSourceID: advance.ID— contract §2.1; Plan 4’s Poster setsjournal_entry.source_kindto the workflow kind andsource_idto the instance’s source id (poster.go:118-124) — none. - A3 Ruling (deviation, accepted — contract §8.1): Each variance-leg graph starts in its own
draftstate (advance_topup:draft → topup_pending → …;advance_refund:draft → refund_pending → …). The acquittal transaction starts the leg indraft; the claimant opens it (draft → topup_pending/draft → refund_pending, reporter gate) in a later transaction — the validator’s rule 1 requires exactly onedraft-semantic state (validate.go:228-248, codedraft_count) andworkflow.Startrefuses a definition without one (instance.go:171-179,errNoDraftSemantic); contract §2.1’s leg graphs have none, and contract §1.2 does not relax rule 1 forcash_only. It also gives wave-3 §5’s “start in an earlier transaction than the firstTransition” a natural resting state — one more click for the claimant per variance. - A4 Ruling (deviation, accepted — contract §8.1): An acquittal of zero does not enter
acquitted. The advance graph has a terminalreturnedstate (semanticcancelled, target gate, OWNER-seat grant, reversal flag set) reachable fromreceived; entering it makes the engine mirror the advance’s payout (Dr <the account the payout credited> / Cr Employee Advances), which is exactly “the whole advance comes back” (§7.3), confirmed by the member who takes the edge. Noadvance_refundleg is started for a zero acquittal —acquittedis an accrual state, and an accrual at 0 cannot post:ledger.Postrefuses a line whose debit and credit are both zero (ledger/post.go:234) and the Poster has no “post nothing” path (poster.go:106-130), so the transition would abort; contract §2.1’s table (accrual “atactual”) and the spec’s= 0row cannot both hold. Rule 3 permits it because reversing states are removed from the graph (validate.go:253-262) and contract §1.1 makes a cancel after payout reverse — the cash-in for a zero acquittal posts through the engine’s reversal rather than a refund rule, so it debits the payout’s own credit account; finance must deposit the returned cash there. - A5 Ruling (deviation, accepted — contract §8.1): A dispute raised after acquittal uses its own states:
acquitted → acquittal_disputed(reporter, reason) andacquittal_disputed → acquittal_confirmed(dismiss, source gate, OWNER seat;acknowledged, terminal;acquittal_confirmed → acquittal_disputedmay be raised again). No uphold edge is seeded fromacquittal_disputed. Dismissing back intoacquittedputs an accrual on a cycle (rule 3(a),accrual_on_cycle); upholding intorejectedwould make the engine mirror every unreversed entry of the advance instance — the payout as well as the accrual — which records cash the claimant really holds as returned, and it cannot reach the variance leg’s entries, so the control would not net to zero. Correcting an acquitted figure is deferred to a later adjustment document kind (controller ruling; contract §8.1; Notes for later plans) — the spec says “disputes are reachable from every post-payout state” and a dispute is reachable; only its uphold is withheld. - A6 Ruling (deviation, accepted — contract §8.1):
advance_refundgainsrejected(uphold target, reversal flag) andrefund_settled(acknowledged, terminal, the dismiss target):refund_confirmed → disputed,disputed → refund_settled(dismiss),disputed → rejected(uphold, reverses the refund),rejected → draft(revise),refund_settled → disputed. Dismissing back intorefund_confirmedputs the payout state on a cycle (payout_on_cycle), and contract §2.1 gives the refund kind no reversing state for an upheld dispute to enter — two more states on the refund graph. - A7 Ruling: The seeded graphs’ gates and grants (all floors nil, i.e. every amount): advance —
draft → pending_reviewreportersubmit;pending_review → reviewedtarget (grant onreviewed:superior);reviewed → approvedtarget (approved:unit_head);approved → paid_outtarget (paid_out: OWNER seat);paid_out → receivedreporteracknowledge;received → acquittedtarget (acquitted: OWNER seat);received → returnedtarget (returned: OWNER seat);pending_review|reviewed|approved → rejectedadvance gatereject;rejected → draftreporterrevise;draft → cancelledreporterwithdraw;paid_out|received → disputedreporter;disputed → receivedsourcedismiss,disputed → rejectedsourceuphold(reverses) (disputed: OWNER seat); A5’s two dispute edges (acquittal_disputed: OWNER seat). advance_topup —draft → topup_pendingreportersubmit;topup_pending → topup_approvedtargetapprove_excess(topup_approved:unit_head);topup_approved → topup_paidtargetpay(topup_paid: OWNER seat);topup_paid → topup_receivedreporteracknowledge;topup_pending|topup_approved → rejectedadvancereject;rejected → draftreporterrevise;topup_paid|topup_received → disputedreporter;disputed → topup_receivedsourcedismiss,disputed → rejectedsourceuphold(reverses) (disputed: OWNER seat). advance_refund —draft → refund_pendingreporterdeclare;refund_pending → refund_confirmedtargetconfirm(refund_confirmed: OWNER seat); A6’s edges (disputed: OWNER seat) — the same authorities Plan 3 seeded forexpense_claim(superior reviews, unit head approves, the root seat pays and verifies), which is what provisioning guarantees exists — an organization re-grants by publishing its own version. - A8 Ruling: The claimant records the acquittal (
PUT /v1/advances/{id}/acquittal: actual, receipt, date, category or reason) while the advance is atreceived; a member holding theacquitted/returnedgrant (the OWNER seat by default) decides it by takingreceived → acquittedorreceived → returned— §7.2 “they supply the real receipt and the real amount” (the recording), while §11A.4 needs “the person who approved it” behind the accrual, and Plan 3’s seed keeps “never accrue to an account no human has verified”; the engine’s self-approval refusal (RefuseSelfApproval) stops a claimant verifying their own — one extra decision per advance. - A9 Ruling: Branching by variance sign is enforced three times: (1) the transition service runs
DecideAcquittalon the stored acquittal and refuses the mismatched exit (ErrAcquittalBranch, 422:acquittedneeds outcomesettled/topup/refund,returnedneeds outcomereturned); (2) it starts only the matching leg (advance_topupfor a positive variance,advance_refundfor a negative one with actual > 0, none for zero); (3) the triggeradvance_variance_leg_guardrefuses a leg id whose instance kind, source or the acquittal’s sign disagree. The read API marks the mismatched exitallowed: falsewith refusal codeacquittal_branch— the engine has no guards (Plan 3 Ruling 2) andAvailableTransitionslists both exits, the same position contract §2.2 takes for Plan 8’s pay edge — none. - A10 Ruling: Entering a payout state of
advance_topuprequires the overspend approval:OverspendApproved(decisions, draftKey, reporter)— true only when, since the leg last left its draft state, some decision wastarget-gated and taken by (and on the authority of) someone other than the reporter. In the seeded graph the engine’s edges already forcetopup_approvedbeforetopup_paid; the check keeps §7.3’s “repaid only after an approver signs off on the excess” true for any version an organization publishes, because accounting reacts to semantics, never state names — a legitimate org graph that pays an overspend with no target-gated step is refused (ErrOverspendNotApproved, 409). - A11 Ruling: Everything that posts is in the organization’s base currency. The header records the requested
currency/amountand freezesfx_rate,fx_rate_sourceandbase_amountfromfx.Resolve(currency → base, TodayUTC)at save (re-resolved only when a draft edit changes amount or currency); the acquittal recordsactual_currency/actual_amountand freezesactual_fx_rate,actual_fx_rate_source,actual_basefromfx.Resolve(actual_currency → base, receipt_date)(a zero acquittal is in base at the identity rate). The instance amount, the settlement, the payout, the accrual, the variance and both legs are in base — Plan 5 R5’s pattern: base frozen at the defining event (§6.5), and Plan 3’s instance amount is base-only — an advance paid in a foreign currency needs an FX-difference path later. - A12 Ruling:
due_on(the acquittal due date, Plan 9 D1’sDueOn, §7.6) is supplied by the claimant at creation, must lie in[TodayUTC, TodayUTC + 366 days], is editable only in draft, and is re-checked at submission (a draft left to go stale cannot be submitted with a past due date) — contract §2.1 requires the date and no organization setting exists to derive it — an approver who disagrees with the date rejects the advance back to draft. - A13 Ruling: References are
AD-YYYY-NNNNfromnumbering.Allocate(ctx, tx, "advance", year)andnumbering.Format("AD", year, n), calendar year ofTodayUTCat creation, inside the creating transaction — contract §2.1, §20.1.3 — none. - A14 Ruling: The advance stores
holder_party_id, the claimant’sorganization_member.party_idread at creation (composite FK toparty);AgingRef.HolderNameis that party’slegal_name— Plan 9 D1 asks forHolderPartyIDand a record-language name, and Plan 5 postsPartyID= the claimant’s party — none. - A15 Ruling: An acquittal has one receipt document, the column
advance_acquittal.receipt_document_id(partial unique indexadvance_acquittal_receipt_once— this consumer’s own one-record rule, contract §1.4). The binding path locks advance → acquittal → document (documents.Lock), then callsdocuments.BoundElsewhere(…, except Binding{Kind: "advance", RecordID: advance.ID})and refuses any binding it returns (*DocumentBoundElsewhereError, wrappingErrDocumentBoundElsewhere, 409 whose detail names only the kind of each record — contract §8.3; theBindingsstill carryRecordIDandReferencefor logs and tests, never for the wire). Replacing the receipt whilereceivedreleases the old document (the column changes; the audit row records both ids). The advance binder also reports a document used as an advance or top-up settlement proof as bound, and settling with a proof does not callBoundElsewhere— contract §7 (own record first, then the document, thenBoundElsewhere; “a settlement proof counts as bound”), matching the builtexpensesbinder (internal/platform/expenses/binder.go) — a multi-receipt acquittal uploads one combined PDF (a child table later is additive). - A16 Ruling: The acquittal row is written only while the advance’s instance is in an
acknowledged-semantic state with the payout open and no accrual open (i.e.received) and never onceacquitted_atis set — enforced by the triggeradvance_acquittal_guardas well as in Go; the acquittal transaction setsacquitted_at,acquitted_byandexpense_account_idbeforeworkflow.Transition(while stillreceived), so a transition that fails rolls them back — Plan 5 R16’s reasoning: a Go-only rule is one forgotten call away from a mutated accrued figure — the trigger couples toworkflow_instance.payout_open(contract §1.1), which Task 1 verifies. - A17 Ruling: The acquittal’s category is an organization
expense_category(Plan 5’s registry, composite FK); its account is resolved withexpenses.UsableAccountwhen the acquittal is recorded and again, then frozen intoexpense_account_id, when it is decided;advancesimportsinternal/platform/expensesforGetCategory,UsableAccountandCheckRecordText— one category list and one account mapping for every expense the org recognises, andexpensesimports nothing ofadvancestoday — whoever enablesadvance_offsetinsideexpensesmust not importadvancesback (see Notes for later plans). - A18 Ruling:
advance_settlementholds one row per payout of theadvanceoradvance_topupkind (columnkind,instance_id):payout.Snapshot’s sealed JSON byte-for-byte, the channel’sCreditAccountIDread then, amount, currency, proof document, actor,txid. The claim-type key passed to Plan 6 is the workflow kind ("advance","advance_topup"), Plan 5 R41’s convention. Channels areexternalonly: apetty_cashchannel is refused insettle(ErrChannelPettyCash, 422 namingpayout_channel_id) and again in both payout rules (ErrPettyCashDeferred). Plan 7 never callspayout.OpenSnapshot;TestPlan7NeverOpensASnapshotguards both packages. The table carries Plan 5’s three sealed-snapshot CHECKs and a reseal-only trigger — contract §2.1 (“external channel only”, “must add its table tomigrate reseal-payout-details”), Plan 5 R24/R25/R44/R45 — no advance paid from the tin until the follow-up contract §4 names. - A19 Ruling: The refund posts
Dr <the account the advance's unreversed payout entry credited> / Cr <the control that payout debited>on the active journal whose default account is that debit account, else the defaultbankjournal; it has no channel and no snapshot — money coming in has no payee details to freeze, and returning cash to the account it left keeps the bank reconciliation one-to-one — a refund banked elsewhere needs a later “refund to account” choice. - A20 Ruling: Every leg after the payout — the accrual, the top-up, the refund — reads the Employee Advances account from the advance’s unreversed payout entry (its one debit line), not from a freshly resolved control;
Source.Control(resolved by the Poster fromControlKindon the accrual) is not used for the credit — Plan 5 R26’s argument turned round for a payout-first flow: an organization re-pointing its control between payout and acquittal must still see this advance net to zero on the account it was issued against — none. - A21 Ruling: The acquittal transaction runs, in order: idempotency key →
advanceFOR UPDATE→advance_acquittalFOR UPDATE→ decide and freeze the acquittal row →workflow.Transition(instance lock,posting.Dispatch, its audit) →workflow.Startof the leg (its audit) →UPDATE advance SET topup_instance_id|refund_instance_id→ the advance’s own audit and activity. ThatUPDATEis the one write after an audit row; it touches the advance row this transaction already holds and its FK check reads only the leg instance this transaction just inserted, so it waits on nothing — the order wave-2 §3 and wave-3 §5 fix, andworkflow.Startmust followTransitionbecause it audits — none. - A22 Ruling: An advance is visible to its claimant, to holders of
advances.advance.read_all, to members with an allowed transition on any of its three instances now, and to members in any of their decision logs; anyone else gets 404.GET /advances?scope=to_actreturns at most 200, oldest-in-state first, without a cursor — Plan 5 R21 extended over the legs — a large org’s queue needs an index-backed query later. - A23 Ruling:
Idempotency-Keyis required onPOST /v1/advances/{id}/transitions, stored in this plan’s ownadvance_idempotency_keytable (scoped(org, member, key); same key with a different request hash is 422; only a successful response is stored; operational, no pruner) — contract §3 gives each plan its own table, and sharing Plan 5’s would make one key namespace span two modules — the table grows until a pruner lands. - A24 Ruling:
PATCH /advances/{id}andPUT /advances/{id}/acquittalrequireIf-Match: "<revision>"(400 missing, 409 stale);GETreturnsETag. The revision moves on a header edit and on every acquittal save, not on a transition (Plan 5’s pattern: the transition’s concurrency control isfromplus the key) — none. - A25 Ruling: Four permissions, appended to
constants.yaml:advances.advance.create(file an advance, edit its draft, record its acquittal),advances.advance.read,advances.advance.read_all(every advance, and the reminder listing),advances.advance.transition(use the transition endpoint; authority still comes from grants) — Plan 5 R37’s split — none. - A26 Ruling: Two reconciliation checks, registered from
internal/platform/advances’sinit():advance_control— per advance, the base balance on accounts of control kindemployee_advances, summed over entries whosesource_kindis inSourceKindsandsource_idis the advance, equals the module’s expected outstanding:base_amount·[advance payout open] − actual_base·[advance accrual open] + topup.amount·[top-up payout open] − refund.amount·[refund payout open]; plus one finding per control-account line whose entry is not an advance kind (the control carries nothing but advances).advance_closed_nets_zero— every closed advance (its instance in a terminal state and its leg, if any, in a terminal state) has a zero base balance on the control across the three kinds. Each finding names the advance (or account) and both figures — §10.6 “Advances control”, contract §2.1 “nets to zero … across the three kinds” — a failing check blocks period close (Plan 4’s precondition), which is intended. - A27 Ruling:
ListForAging(ctx, tx, asOf)returns every advance of the calling org with at least onepayoutentry of kindadvancedated on or beforeasOf,IssuedOn= the earliest such entry’s date (a reversed-and-repaid advance keeps its first issue date), ordered byIssuedOn,Reference; it returns reversed advances too, because Plan 9 drops zero-balance ones with its ownHAVING— Plan 9 D1’s text (“every advance issued on or before asOf”) and itsAgingReffield list, verbatim — none. - A28 Ruling: Reminders are computed, not delivered (contract §4).
Overdue(ctx, tx, asOf)lists every advance whose payout is open, whose accrual is not, which is not in a reversing state, and whosedue_onis beforeasOf, withDaysOverdue,Level(claimantfor 1–7 days,managerfrom day 8:ManagerEscalationAfterDays = 7) andRecipients(the claimant, plusorgstruct.SuperiorOf(claimant, asOf)at manager level;ManagerUnresolvedwhen that is empty). Served asGET /v1/advances/reminders?as_of=(advances.advance.read_all) and as anoverdueblock on the advance read. No River, no mailer, no job: the ROADMAP sub-item “Acquittal reminders” stays unticked with a note — River lands with Plan 9, no mailer exists, and the worker cannot enumerate tenants under RLS (Plan 4 R14) — the seven-day threshold is confirmed by the controller as a named constant (ManagerEscalationAfterDays); changing it is a one-line change. - A29 Ruling: Plan 7 registers no provisioning step: its three graphs reach every org through step 60 (
workflow_definitions), which seeds any kind with no version (contract §1.3), and its categories and channels are Plan 5’s and Plan 6’s; existing orgs gain the kinds on the nextPOST /v1/org/provision(DEPLOY_NOTES) — none; orders 110–119 stay reserved. - A30 Ruling (controller ruling 2026-09-22, contract §8.1): The acquittal captures the §6.7 tax record —
document_basis,supplier_name,supplier_tin,einvoice_uuid, andrelaxation_reason(which therelaxation_receiptbasis requires) — with exactly a claim receipt line’s rules. They are validated by the base’s new database-free packageinternal/platform/taxrecord(extracted fromexpenses’normalizeTaxRecord), never copied: at recording, the shape (identifier patterns after upper-casing, e-Invoice identifiers present for an e-Invoice basis, a reason for a relaxation receipt, a known basis, record-text language) and the category rule for an employee-named e-Invoice; at the decision (received → acquitted), completeness — a basis is present when anything was spent (Plan 5 R14’s submission half). A zero acquittal carries no tax record at all.00130mirrors00072’s named CHECKs one for one, each citing the constraint it repeats — acquittal is where the expense is recognised and its receipt is the tax document (§6.7, §11A.6), and identifiers cannot be backfilled onto settled documents — the taxrecord API names are expected, not yet built; Task 1 reconciles them. - A31 Ruling:
advance_offsetstays deferred (contract §2.1, Plan 5 R11): an advance settles only through its own acquittal — none. - A32 Ruling: An approver who refuses the excess rejects the top-up (
topup_pending|topup_approved → rejected); the leg’s only way on isrejected → draft(revise and resubmit). While it sits there the control carries the excess as owed to the claimant, the advance is not closed,advance_controlpasses (the expected figure includes it), andListForAgingshows it — the spec says repayment waits for a signature and is silent on a refused one; re-acquitting at the approved figure would need an accrual-only reversal the engine cannot express (A5) — kept by the controller (2026-09-22, contract §8.1): the excess stays owed and visible and the advance stays open; closing it needs a write-off / adjustment document kind, a later plan’s (Notes for later plans). - A33 Ruling: The binder’s
MayRead(ctx, tx, c, documentID, memberID)first checksmember.Can(ctx, tx, memberID, ReadAllPermission)(advances.advance.read_all) and answers true when the document backs any advance; otherwise it answers withCanRead(ctx, tx, c, a, memberID, false)for each advance the document backs, using the clock it is handed — contract §8.3:GET /v1/documents/{id}is now authenticated-only and the data rule decides (uploader, any binder’sMayRead, plus the expenseread_allshortcut), and each plan’s binder honours its ownread_allinsideMayRead, as Plan 8’s does — none. Uploads (POST /v1/documents) need the base’s newdocuments.document.upload, seeded on every filing role; Plan 7 adds no document route. - A34 Ruling: Audit entity kinds
advance(create, update, transition),advance_acquittal(record) andadvance_settlement(reseal); activity rows for advance created, acquittal recorded, and each transition (verbssubmitted,paid,acquitted,returned,transitioned) — Plan 5 R38’s pattern — none.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| File | Task | Responsibility |
|---|---|---|
00130_advances.sql |
4 | advance, advance_acquittal; draft-only header trigger; acquittal-while-received trigger; variance-leg trigger; RLS; DELETE revoked. |
00131_advance_settlement.sql |
4 | advance_settlement (sealed snapshot CHECKs, generated channel_key/account_tail, UPDATE (snapshot) only, reseal-only trigger), advance_idempotency_key (operational). |
New Go packages
Section titled “New Go packages”| Path | Task(s) | Responsibility |
|---|---|---|
internal/platform/advances/ |
2, 3, 5–13, 15 | doc.go (kinds, SourceKinds, constants), decide.go (pure), graphs.go (seed registration), advance.go, visibility.go, cursor.go, submission.go, acquittal.go, binder.go, settlement.go, transition.go, overspend.go (pure), available.go, reseal.go, posting.go, posting_advance.go, posting_variance.go, aging.go, reminders.go, reconcile.go, idempotency.go. |
internal/platform/advances/advancestest/ |
4, 8 | The test world: expensestest.Env (a provisioned MY org whose step 60 also seeded the three advance kinds), walk helpers, control-balance reader. |
internal/modules/advances/ |
14, 15 | Permissions, routes, handlers, boot (posting rules). |
Modified (shared, append-only)
Section titled “Modified (shared, append-only)”| Path | Task | Change |
|---|---|---|
internal/kernel/db/retention_test.go |
4 | Four tables into their lists. |
sqlc.yaml |
4 | money.Rate overrides for advance.fx_rate, advance_acquittal.actual_fx_rate. |
cmd/migrate/reseal.go, docs/DEPLOY_NOTES.md |
9 | Advance settlements join the reseal walk; a Plan 7 section on top. |
constants.yaml (+ generated vocab) |
14 | Four advances.* permissions. |
api/openapi.yaml (+ internal/gen/api) |
14, 15 | /advances/…. |
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go |
14 | Compose the module. |
docs/ROADMAP.md |
16 | Plan 7’s lines. |
Errors are declared in the file that raises them (no shared errors.go in the platform package), so tasks in one wave never edit the same file.
Task 1: Stand on the built base — rebase onto phase1-wave3 and confirm the seam
Section titled “Task 1: Stand on the built base — rebase onto phase1-wave3 and confirm the seam”No code. The base branch was built while this plan was written, and every symbol below was read on it (phase1-wave3 at 1d9ddab). This task makes sure that is still true on the commit you build on. No task marked “waits for Task 1” starts before it is committed.
Files:
-
Modify:
docs/superpowers/plans/2026-09-22-plan-7-advances.md(only if something moved: the affected briefs and the Reconciliation record) -
Step 1: Rebase.
git fetch && git rebase phase1-wave3(or ontomainonce the base has merged there — the user merges; you only rebase this branch). Rungo test ./internal/platform/workflow/... ./internal/platform/documents/... ./internal/platform/posting/... -count=1→ PASS. -
Step 2: Confirm each row of the Reconciliation record still holds (open the file at the line given; a moved line number is fine, a changed name or signature is not):
| # | Symbol used here | Used by task(s) |
|---|---|---|
| 1 | posting.Event.PastPayout; PostsToLedger() true for Rejected/Cancelled when PastAccrual || PastPayout |
10, 11 |
| 2 | workflow_instance.payout_open (migration 00034): set entering a payout, cleared entering rejected/cancelled |
4, 12, 13 |
| 3 | workflow.Instance.PayoutOpen bool |
6, 8, 12 |
| 4 | EdgeSpec.ReversesAccrual — the flag rule 5 now demands past an accrual or a payout |
3 |
| 5 | workflow.CashOnly; rule 3 under it: code accrual_in_cash_only, (a) and (b) kept, (c) skipped |
3 |
| 6 | Rule 1 unchanged for cash_only; workflow.Start still needs a draft state |
3 (Ruling A3) |
| 7 | workflow.RegisterSeededGraph(kind string, build func(ownerSeat uuid.UUID) workflow.Graph) — calls build(uuid.Nil) at registration to check the kind; SeededGraphs = expense_claim, then kind order |
3 |
| 8 | workflowtest.RequireSeededGraphValid(t workflowtest.TB, kind string) |
3 |
| 9 | documents.Binder{Kind(); BoundTo(ctx, tx, documentID); MayRead(ctx, tx, c clock.Clock, documentID, memberID)}, documents.Binding{Kind, RecordID, Reference}, RegisterBinder, UnregisterBinderForTest, BoundElsewhere(ctx, tx, documentID, except Binding), MayRead(ctx, tx, c, documentID, memberID) |
6 |
| 10 | expenses.ErrDocumentBoundElsewhere, *expenses.DocumentBoundElsewhereError{Bindings}; AttachDocument locks claim, then document, then BoundElsewhere |
6 |
| 11 | modules/expenses’ GET /v1/documents/:id: uploader, expenses.claim.read_all, then documents.MayRead |
16 |
| 12 | workflow.Refusal has a Code string field |
8 |
| 13 | ledger.ReverseAll copies source_kind/source_id onto the reversal |
10, 11, 13 |
| 14 | internal/platform/taxrecord (lands on phase1-wave3 after this plan was written; contract §8.1). Expected: taxrecord.Record{DocumentBasis, SupplierName, SupplierTIN, EInvoiceUUID, RelaxationReason *string}; taxrecord.Normalize(r Record) (Record, error) — the save-time shape; taxrecord.Complete(r Record) error — a basis is present; taxrecord.CheckEmployeeNamed(basis *string, allowed bool) error; the basis constants taxrecord.BasisEInvoiceCompany, BasisEInvoiceEmployee, BasisForeignSupplier, BasisRelaxationReceipt; refusals as *taxrecord.FieldError{Field, Rule, Detail} with Plan 5’s rule codes (tin_shape, einvoice_uuid_shape, einvoice_identifiers, relaxation_reason, basis_unknown, employee_named_not_allowed, required) |
4, 6, 8, 15 |
| 15 | Document routes as amended (contract §8.3): POST /v1/documents needs documents.document.upload; GET /v1/documents/{id} authenticated-only, deciding by uploader, documents.MayRead, or expenses.claim.read_all; the bound-elsewhere 409 names only the kind |
6, 15, 16 |
-
Step 3: If anything moved, edit the affected task’s Interfaces and code blocks to the name as built and record it in the appendix; if a symbol is gone, stop and report to the user. Row 14 is expected to differ in names: rewrite every
taxrecord.reference in Tasks 4, 6, 8 and 15 to the package as built (function names, the record type’s field names, the error type and its rule codes), and if the built migration CHECK list in00072differs from Task 4’s mirror, follow00072. If nothing moved, add one line to the appendix: “Confirmed on<sha>; no change.” -
Step 4: Commit.
git add docs/superpowers/plans/2026-09-22-plan-7-advances.mdgit commit -m "Plan 7: confirm the phase1-wave3 seam on the build base
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 2: The acquittal decision table — a pure function, heavily table-tested
Section titled “Task 2: The acquittal decision table — a pure function, heavily table-tested”Needs nothing. Runs before the base lands.
Files:
- Create:
internal/platform/advances/doc.go,internal/platform/advances/decide.go - Test:
internal/platform/advances/decide_test.go
Interfaces:
-
Consumes:
money.Amount(IsPositive,IsNegative,IsZero,Sub,Neg,Cmp,money.Zero,money.MustParsein tests — use whichever parse helperinternal/kernel/moneyexports; Plan 5’s tests use it). -
Produces:
package advancesconst (Kind = "advance"KindTopup = "advance_topup"KindRefund = "advance_refund"ControlKind = "employee_advances"BinderKind = "advance"ReadAllPermission = "advances.advance.read_all"EntityAdvance = "advance"EntityAcquittal = "advance_acquittal"EntitySettlement = "advance_settlement")// SourceKinds is every journal_entry.source_kind an advance posts under// (contract §2.1). Plan 9's aged-advances query sums over this set.var SourceKinds = []string{Kind, KindTopup, KindRefund}type Outcome stringconst (OutcomeSettled Outcome = "settled" // actual = approved: terminal at acquittedOutcomeTopup Outcome = "topup" // actual > approved: advance_topup pays the excessOutcomeRefund Outcome = "refund" // 0 < actual < approved: advance_refund brings the rest backOutcomeReturned Outcome = "returned" // actual = 0: received -> returned reverses the payout (Ruling A4))type AcquittalFacts struct {Approved money.Amount // the advance's base_amountActualAmount money.Amount // as entered, in the receipt's currencyActualBase money.Amount // ActualAmount at the receipt date's rateReceiptDate *time.TimeReceiptDocument *uuid.UUIDCategoryID *uuid.UUIDReason stringToday time.Time // the business date; the function reads no clock}type AcquittalDecision struct {Outcome OutcomeVariance money.Amount // ActualBase - Approved, signedLegAmount money.Amount // |Variance| for topup/refund, zero otherwise}// AcquittalError names the one field and rule that failed.type AcquittalError struct{ Field, Rule, Detail string }func (e *AcquittalError) Error() string // "field=<Field> rule=<Rule>: <Detail>"func DecideAcquittal(f AcquittalFacts) (AcquittalDecision, error) -
Step 1: Write the failing tests —
decide_test.go, packageadvances(internal; no database):
func TestDecideAcquittal(t *testing.T) { today := time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC) doc, cat := uuid.New(), uuid.New() d := func(y, m, day int) *time.Time { v := time.Date(y, time.Month(m), day, 0, 0, 0, 0, time.UTC); return &v } type want struct { outcome advances.Outcome variance, leg string field, rule string // set when an *AcquittalError is expected } cases := []struct { name string f advances.AcquittalFacts want want }{ // PRESENCE: the four accepted rows of spec 7.3. {"exact", facts("1000", "1000", "1000", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeSettled, variance: "0", leg: "0"}}, {"overspend", facts("1000", "1200", "1200", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeTopup, variance: "200", leg: "200"}}, {"underspend", facts("1000", "700", "700", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeRefund, variance: "-300", leg: "300"}}, {"nothing spent", facts("1000", "0", "0", nil, nil, nil, "Trip cancelled"), want{outcome: advances.OutcomeReturned, variance: "-1000", leg: "0"}}, {"one sen over", facts("1000", "1000.0100", "1000.0100", d(2026, 9, 20), &doc, &cat, ""), want{outcome: advances.OutcomeTopup, variance: "0.0100", leg: "0.0100"}}, {"receipt dated today", facts("1000", "10", "10", d(2026, 9, 21), &doc, &cat, ""), want{outcome: advances.OutcomeRefund, variance: "-990", leg: "990"}}, {"foreign receipt converted", facts("1000", "300", "1260", d(2026, 9, 19), &doc, &cat, ""), want{outcome: advances.OutcomeTopup, variance: "260", leg: "260"}}, {"reason allowed with a receipt", facts("1000", "1200", "1200", d(2026, 9, 20), &doc, &cat, "Fare rose"), want{outcome: advances.OutcomeTopup, variance: "200", leg: "200"}}, // ABSENCE: every refusal, one field each. {"negative actual", facts("1000", "-1", "-1", d(2026, 9, 20), &doc, &cat, ""), want{field: "actual_amount", rule: "negative"}}, {"negative base", facts("1000", "1", "-1", d(2026, 9, 20), &doc, &cat, ""), want{field: "actual_amount", rule: "negative"}}, {"zero base for a real amount", facts("1000", "1", "0", d(2026, 9, 20), &doc, &cat, ""), want{field: "actual_base", rule: "mismatch"}}, {"spent without a receipt", facts("1000", "900", "900", d(2026, 9, 20), nil, &cat, ""), want{field: "receipt_document_id", rule: "required"}}, {"spent without a date", facts("1000", "900", "900", nil, &doc, &cat, ""), want{field: "receipt_date", rule: "required"}}, {"receipt from the future", facts("1000", "900", "900", d(2026, 9, 22), &doc, &cat, ""), want{field: "receipt_date", rule: "future"}}, {"spent without a category", facts("1000", "900", "900", d(2026, 9, 20), &doc, nil, ""), want{field: "category_id", rule: "required"}}, {"nothing spent, no reason", facts("1000", "0", "0", nil, nil, nil, " "), want{field: "reason", rule: "required"}}, {"nothing spent, yet a receipt", facts("1000", "0", "0", nil, &doc, nil, "Trip cancelled"), want{field: "receipt_document_id", rule: "none_exists"}}, {"nothing spent, yet a date", facts("1000", "0", "0", d(2026, 9, 20), nil, nil, "Trip cancelled"), want{field: "receipt_date", rule: "none_exists"}}, {"nothing spent, yet a category", facts("1000", "0", "0", nil, nil, &cat, "Trip cancelled"), want{field: "category_id", rule: "none_exists"}}, {"no approved amount", facts("0", "10", "10", d(2026, 9, 20), &doc, &cat, ""), want{field: "approved", rule: "invalid"}}, } // facts(approved, actualAmount, actualBase, date, doc, cat, reason) builds // AcquittalFacts with Today = today (a helper in this file). // For each case: DecideAcquittal; either require.NoError and compare // Outcome, Variance.Cmp(want) == 0 and LegAmount.Cmp(want) == 0, or // errors.As(*advances.AcquittalError) with Field and Rule equal and the // decision zero-valued.}
func TestVarianceSignDecidesTheOutcomeAcrossAGrid(t *testing.T) { // Approved 1000. For actual in {0.0001, 1, 999.9999, 1000, 1000.0001, // 5000}: Outcome is Refund, Refund, Refund, Settled, Topup, Topup; and // always Variance == actual - 1000 and LegAmount == |Variance| (zero when // settled). Actual 0 is Returned with Variance -1000 and LegAmount 0.}
func TestAcquittalErrorNamesFieldAndRule(t *testing.T) { // (&AcquittalError{Field: "receipt_date", Rule: "future", Detail: "…"}).Error() // == "field=receipt_date rule=future: …" — the shape Plan 5 R43 fixed.}
func TestDecideTouchesNoDatabase(t *testing.T) { // go/parser over decide.go: its imports are a subset of // {"errors", "fmt", "strings", "time", "github.com/google/uuid", // "github.com/mcom/erp/internal/kernel/money"}. // PRESENCE: the file parsed and listed at least three imports.}-
Step 2: Run and watch fail.
go test ./internal/platform/advances/ -run 'Decide|Variance|AcquittalError' -count=1→ FAIL (undefined). -
Step 3: Implement.
doc.gocarries the package comment (what an advance is, §7.1’s rule, the three kinds and why they are three — contract §2.1 — and the constants above).decide.go:
// DecideAcquittal is spec 7.3's decision table, kept database-free (korre's// validateAcquittal, adopted). Checks run in a fixed order and the first// failure is returned, so a caller can show one field at a time.func DecideAcquittal(f AcquittalFacts) (AcquittalDecision, error) { fail := func(field, rule, detail string) (AcquittalDecision, error) { return AcquittalDecision{}, &AcquittalError{Field: field, Rule: rule, Detail: detail} } if !f.Approved.IsPositive() { return fail("approved", "invalid", "an advance is approved at a positive amount") } if f.ActualAmount.IsNegative() || f.ActualBase.IsNegative() { return fail("actual_amount", "negative", "an amount spent cannot be negative") } if f.ActualAmount.IsZero() != f.ActualBase.IsZero() { return fail("actual_base", "mismatch", "the converted amount is zero exactly when the amount is") } if f.ActualAmount.IsPositive() { switch { case f.ReceiptDocument == nil: return fail("receipt_document_id", "required", "money was spent, so a receipt is required") case f.ReceiptDate == nil: return fail("receipt_date", "required", "the date on the receipt is required") case f.ReceiptDate.After(f.Today): return fail("receipt_date", "future", "a receipt cannot be dated after today") case f.CategoryID == nil: return fail("category_id", "required", "the category says which expense account the receipt is charged to") } v := f.ActualBase.Sub(f.Approved) switch v.Cmp(money.Zero()) { case 1: return AcquittalDecision{Outcome: OutcomeTopup, Variance: v, LegAmount: v}, nil case -1: return AcquittalDecision{Outcome: OutcomeRefund, Variance: v, LegAmount: v.Neg()}, nil } return AcquittalDecision{Outcome: OutcomeSettled, Variance: v, LegAmount: money.Zero()}, nil } switch { case strings.TrimSpace(f.Reason) == "": return fail("reason", "required", "nothing was spent, so say why the whole advance is coming back") case f.ReceiptDocument != nil: return fail("receipt_document_id", "none_exists", "nothing was spent, so there is no receipt to attach") case f.ReceiptDate != nil: return fail("receipt_date", "none_exists", "nothing was spent, so there is no receipt date") case f.CategoryID != nil: return fail("category_id", "none_exists", "nothing was spent, so nothing is charged to a category") } return AcquittalDecision{Outcome: OutcomeReturned, Variance: f.Approved.Neg(), LegAmount: money.Zero()}, nil}-
Step 4: Run. PASS. Sabotage: change
ReceiptDate.After(f.Today)to!ReceiptDate.Before(f.Today)→ “receipt dated today” goes red; quote; restore. -
Step 5: Commit —
git add internal/platform/advances && git commit -m "Decide advance acquittals with a pure, table-tested function".
Task 3: The three seeded graphs — registered, validated, pinned
Section titled “Task 3: The three seeded graphs — registered, validated, pinned”Waits for Task 1. Needs Task 2 (doc.go).
Files:
- Create:
internal/platform/advances/graphs.go - Test:
internal/platform/advances/graphs_test.go
Interfaces:
-
Consumes (as built on
phase1-wave3):workflow.Graph,StateSpec,EdgeSpec,GrantSpec,Labels, gates, grantee kinds,workflow.PayoutFirst,workflow.CashOnly,workflow.RegisterSeededGraph,workflow.SeededGraphs,workflow.ValidateGraph,workflowtest.RequireSeededGraphValid;postingsemantics. -
Produces:
func AdvanceGraph(ownerSeat uuid.UUID) workflow.Graph // payout_firstfunc TopupGraph(ownerSeat uuid.UUID) workflow.Graph // cash_onlyfunc RefundGraph(ownerSeat uuid.UUID) workflow.Graph // cash_only// init(): RegisterSeededGraph(Kind, AdvanceGraph), (KindTopup, TopupGraph), (KindRefund, RefundGraph).State keys (data, but pinned by tests because the seeds are the contract): advance
draft(draft) ·pending_review,reviewed,approved(pending) ·paid_out(payout) ·received(acknowledged) ·acquitted(accrual, controlemployee_advances, terminal) ·returned(cancelled, terminal) ·rejected(rejected) ·cancelled(cancelled, terminal) ·disputed(dispute) ·acquittal_disputed(dispute) ·acquittal_confirmed(acknowledged, terminal). advance_topupdraft·topup_pending,topup_approved(pending) ·topup_paid(payout) ·topup_received(acknowledged, terminal) ·rejected·disputed. advance_refunddraft·refund_pending(pending) ·refund_confirmed(payout, terminal) ·refund_settled(acknowledged, terminal) ·rejected·disputed. -
Step 1: Write the failing tests —
graphs_test.go:
func TestEachAdvanceGraphIsValid(t *testing.T) { for _, k := range advances.SourceKinds { workflowtest.RequireSeededGraphValid(t, k) }}func TestTheGraphsAreRegisteredForSeeding(t *testing.T) { // kinds of workflow.SeededGraphs(uuid.New()) == ["expense_claim", // "advance", "advance_refund", "advance_topup"] (expense_claim first, // then kind order, contract §1.3) — or a superset when another plan's // package is linked; assert the advance kinds appear in that order after // expense_claim.}func TestTheAdvanceGraphIsPayoutFirst(t *testing.T) { // PostingOrder == workflow.PayoutFirst; the state table above exactly // (key, semantic, is_terminal, control_kind), and exactly one accrual // (acquitted, "employee_advances") and one payout (paid_out).}func TestTheVarianceGraphsAreCashOnly(t *testing.T) { // TopupGraph and RefundGraph: PostingOrder == workflow.CashOnly, no // accrual state, exactly one payout state (topup_paid / refund_confirmed), // exactly one draft (Ruling A3); the state tables above exactly.}func TestEveryPostPayoutStateCanBeDisputed(t *testing.T) { // For each graph: every non-dispute, non-reversing state reachable from // its payout state without entering a reversing state has an edge into a // dispute-semantic state (spec 7.2). PRESENCE: the walk visited >= 3 // states in the advance graph and >= 2 in each leg graph.}func TestEveryExitToAReversingStateAfterPayoutReverses(t *testing.T) { // For each graph, every edge into a rejected/cancelled state from a state // at or past the payout carries ReversesAccrual (rule 5 as amended, §1.1): // advance disputed->rejected, received->returned; topup disputed->rejected; // refund disputed->rejected. And no edge from a pre-payout state carries // it (pending_review->rejected, draft->cancelled, topup_pending->rejected).}func TestTheGrantsAreTheSeededAuthorities(t *testing.T) { // Ruling A7's table: reviewed<-superior, approved<-unit_head, // paid_out/acquitted/returned/disputed/acquittal_disputed<-position // ownerSeat; topup_approved<-unit_head, topup_paid/disputed<-ownerSeat; // refund_confirmed/disputed<-ownerSeat. Every MinAmount nil.}func TestTheContractsLiteralGraphsWouldBeRefused(t *testing.T) { // Documents Rulings A3, A5, A6 as executable facts, each with PRESENCE // first (the seeded graph validates clean): // (a) TopupGraph with its draft state and draft edges removed and // topup_pending as the entry -> ValidateGraph has code "draft_count"; // (b) AdvanceGraph plus edge acquittal_disputed -> acquitted (source, // dismiss) -> code "accrual_on_cycle"; // (c) RefundGraph with disputed -> refund_confirmed replacing // disputed -> refund_settled -> code "payout_on_cycle".}-
Step 2: Run and watch fail.
go test ./internal/platform/advances/ -run 'Graph|Seed|Dispute|Revers|Grant|Contract' -count=1→ FAIL. -
Step 3: Implement
graphs.go:
func init() { workflow.RegisterSeededGraph(Kind, AdvanceGraph) workflow.RegisterSeededGraph(KindTopup, TopupGraph) workflow.RegisterSeededGraph(KindRefund, RefundGraph)}
func ls(en, zh string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh} }
func st(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec { s := workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l} if sem == posting.Accrual { s.ControlKind = ControlKind // Employee Advances for an advance (spec 5.5) } return s}
func ed(order int, from, to string, gate workflow.Gate, action string, l workflow.Labels) workflow.EdgeSpec { return workflow.EdgeSpec{From: from, To: to, Gate: gate, ActionKey: action, DisplayOrder: order, Labels: l}}
// why marks an edge that needs a reason; reverses sets ReversesAccrual, the// flag rule 5 demands past an accrual or a payout (wave-3 §1.1).func why(e workflow.EdgeSpec, reverses bool) workflow.EdgeSpec { e.RequiresReason, e.ReversesAccrual = true, reverses return e}
func seat(state string, ownerSeat uuid.UUID) workflow.GrantSpec { return workflow.GrantSpec{StateKey: state, GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}}
// AdvanceGraph is spec 7.2's advance up to acquittal (contract §2.1), with// Rulings A4 (returned) and A5 (the acquittal dispute)://// draft → pending_review → reviewed → approved → paid_out → received → acquitted// │ └──────────┴──────────┴─→ rejected → draft │ ├─→ acquittal_disputed ⇄ acquittal_confirmed// └─→ cancelled paid_out, received ─→ disputed ─→ received | rejected (reverses)// received ─→ returned (reverses the payout)func AdvanceGraph(ownerSeat uuid.UUID) workflow.Graph { reject, dispute := ls("Reject", "拒绝"), ls("Raise dispute", "提出争议") return workflow.Graph{ Kind: Kind, PostingOrder: workflow.PayoutFirst, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿")), st(2, "pending_review", posting.Pending, false, ls("Pending review", "待审核")), st(3, "reviewed", posting.Pending, false, ls("Reviewed", "已审核")), st(4, "approved", posting.Pending, false, ls("Approved", "已批准")), st(5, "paid_out", posting.Payout, false, ls("Paid out", "已支付")), st(6, "received", posting.Acknowledged, false, ls("Received", "已收款")), st(7, "acquitted", posting.Accrual, true, ls("Acquitted", "已核销")), st(8, "returned", posting.Cancelled, true, ls("Returned in full", "已全额退回")), st(9, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝")), st(10, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回")), st(11, "disputed", posting.Dispute, false, ls("Disputed", "有争议")), st(12, "acquittal_disputed", posting.Dispute, false, ls("Acquittal disputed", "核销有争议")), st(13, "acquittal_confirmed", posting.Acknowledged, true, ls("Acquittal confirmed", "核销已确认")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_review", workflow.GateReporter, "submit", ls("Submit", "提交")), ed(2, "pending_review", "reviewed", workflow.GateTarget, "review", ls("Mark reviewed", "审核通过")), ed(3, "reviewed", "approved", workflow.GateTarget, "approve", ls("Approve", "批准")), ed(4, "approved", "paid_out", workflow.GateTarget, "pay", ls("Mark paid", "标记已支付")), ed(5, "paid_out", "received", workflow.GateReporter, "acknowledge", ls("Confirm receipt", "确认收款")), ed(6, "received", "acquitted", workflow.GateTarget, "acquit", ls("Accept acquittal", "接受核销")), why(ed(7, "received", "returned", workflow.GateTarget, "confirm_return", ls("Confirm full return", "确认全额退回")), true), why(ed(8, "pending_review", "rejected", workflow.GateAdvance, "reject", reject), false), why(ed(9, "reviewed", "rejected", workflow.GateAdvance, "reject", reject), false), why(ed(10, "approved", "rejected", workflow.GateAdvance, "reject", reject), false), ed(11, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改")), why(ed(12, "draft", "cancelled", workflow.GateReporter, "withdraw", ls("Withdraw", "撤回")), false), why(ed(13, "paid_out", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(14, "received", "disputed", workflow.GateReporter, "dispute", dispute), false), ed(15, "disputed", "received", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), why(ed(16, "disputed", "rejected", workflow.GateSource, "uphold", ls("Uphold dispute", "支持争议")), true), why(ed(17, "acquitted", "acquittal_disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(18, "acquittal_confirmed", "acquittal_disputed", workflow.GateReporter, "dispute", dispute), false), ed(19, "acquittal_disputed", "acquittal_confirmed", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), }, Grants: []workflow.GrantSpec{ {StateKey: "reviewed", GranteeKind: workflow.GranteeSuperior}, {StateKey: "approved", GranteeKind: workflow.GranteeUnitHead}, seat("paid_out", ownerSeat), seat("acquitted", ownerSeat), seat("returned", ownerSeat), seat("disputed", ownerSeat), seat("acquittal_disputed", ownerSeat), }, }.Normalized()}
// TopupGraph pays an overspend once an approver has signed off on the// excess (spec 7.3; contract §2.1 plus Ruling A3's draft).func TopupGraph(ownerSeat uuid.UUID) workflow.Graph { reject, dispute := ls("Reject", "拒绝"), ls("Raise dispute", "提出争议") return workflow.Graph{ Kind: KindTopup, PostingOrder: workflow.CashOnly, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿")), st(2, "topup_pending", posting.Pending, false, ls("Excess awaiting approval", "超支待批准")), st(3, "topup_approved", posting.Pending, false, ls("Excess approved", "超支已批准")), st(4, "topup_paid", posting.Payout, false, ls("Top-up paid", "补付已支付")), st(5, "topup_received", posting.Acknowledged, true, ls("Top-up received", "补付已收款")), st(6, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝")), st(7, "disputed", posting.Dispute, false, ls("Disputed", "有争议")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "topup_pending", workflow.GateReporter, "submit", ls("Request top-up", "申请补付")), ed(2, "topup_pending", "topup_approved", workflow.GateTarget, "approve_excess", ls("Approve excess", "批准超支")), ed(3, "topup_approved", "topup_paid", workflow.GateTarget, "pay", ls("Mark paid", "标记已支付")), ed(4, "topup_paid", "topup_received", workflow.GateReporter, "acknowledge", ls("Confirm receipt", "确认收款")), why(ed(5, "topup_pending", "rejected", workflow.GateAdvance, "reject", reject), false), why(ed(6, "topup_approved", "rejected", workflow.GateAdvance, "reject", reject), false), ed(7, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改")), why(ed(8, "topup_paid", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(9, "topup_received", "disputed", workflow.GateReporter, "dispute", dispute), false), ed(10, "disputed", "topup_received", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), why(ed(11, "disputed", "rejected", workflow.GateSource, "uphold", ls("Uphold dispute", "支持争议")), true), }, Grants: []workflow.GrantSpec{ {StateKey: "topup_approved", GranteeKind: workflow.GranteeUnitHead}, seat("topup_paid", ownerSeat), seat("disputed", ownerSeat), }, }.Normalized()}
// RefundGraph brings an underspend back (spec 7.4; contract §2.1 plus// Rulings A3 and A6).func RefundGraph(ownerSeat uuid.UUID) workflow.Graph { dispute := ls("Raise dispute", "提出争议") return workflow.Graph{ Kind: KindRefund, PostingOrder: workflow.CashOnly, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿")), st(2, "refund_pending", posting.Pending, false, ls("Refund declared", "退款已申报")), st(3, "refund_confirmed", posting.Payout, true, ls("Refund confirmed", "退款已确认")), st(4, "refund_settled", posting.Acknowledged, true, ls("Refund settled", "退款已结清")), st(5, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝")), st(6, "disputed", posting.Dispute, false, ls("Disputed", "有争议")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "refund_pending", workflow.GateReporter, "declare", ls("Declare refund", "申报退款")), ed(2, "refund_pending", "refund_confirmed", workflow.GateTarget, "confirm", ls("Confirm refund received", "确认已收退款")), why(ed(3, "refund_confirmed", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(4, "refund_settled", "disputed", workflow.GateReporter, "dispute", dispute), false), ed(5, "disputed", "refund_settled", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), why(ed(6, "disputed", "rejected", workflow.GateSource, "uphold", ls("Uphold dispute", "支持争议")), true), ed(7, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改")), }, Grants: []workflow.GrantSpec{seat("refund_confirmed", ownerSeat), seat("disputed", ownerSeat)}, }.Normalized()}-
Step 4: Run.
go test ./internal/platform/advances/ ./internal/platform/workflow/... -count=1→ PASS (the base’s drift test overSeededGraphsnow covers these three as well). -
Step 5: Sabotage. Clear the reversal flag on the advance’s
disputed → rejected→TestEachAdvanceGraphIsValidfails with rule 5reversal_missing(contract §1.1); quote; restore. Then drop theacquittal_disputedstate and its edges →TestEveryPostPayoutStateCanBeDisputedfails namingacquitted; quote; restore. -
Step 6: Commit —
git commit -m "Seed the advance, top-up and refund workflows through the graph registry".
Task 4: Migrations 00130–00131 — the advance, its acquittal and settlements; the test world
Section titled “Task 4: Migrations 00130–00131 — the advance, its acquittal and settlements; the test world”Waits for Task 1. Needs Task 3 (the fixture’s step 60 must seed the three kinds).
Files:
- Create:
db/migrations/00130_advances.sql,db/migrations/00131_advance_settlement.sql,internal/platform/advances/advancestest/env.go - Modify:
internal/kernel/db/retention_test.go,sqlc.yaml(append overridesadvance.fx_rate,advance_acquittal.actual_fx_rate→money.Rate), thenmake generate - Test:
internal/platform/advances/schema_test.go,internal/platform/advances/advancestest/env_test.go
Interfaces:
-
Consumes: Plan 3’s
workflow_instance,workflow_state,workflow.Start,workflow.Transition,workflow.Latest; contract §1.1’sworkflow_instance.payout_open(migration00034); Plan 5’sdocument,expense_category; Plan 4’saccount;expensestest.New,expensestest.Env(all of Plan 5’s helpers:Tx,TxErr,Member,Document,Channel,CreditAccount,SeedChannel,PayoutProfile,Count,UsePoster,Categories,Owner,Staff,OwnerSeat,Clock,H). -
Produces the tables every later task uses verbatim (the SQL below), and:
package advancestest// Env is Plan 5's provisioned MY world. Because this package imports// internal/platform/advances, the three advance graphs are registered// before provisioning runs, so step 60 publishes them at v1 too.type Env struct{ *expensestest.Env }func New(t *testing.T) *Envfunc (e *Env) Another(t *testing.T) *Env// NopPoster registers a poster that posts nothing, for tests of the// advance side alone (Tasks 5–9); UseAdvancePoster (Task 10) replaces it.func (e *Env) NopPoster(t *testing.T)// Control is the Employee Advances control account (MY 1550).func (e *Env) Control(t *testing.T) uuid.UUID// ControlBalance is the advance's signed base balance on every account// of control kind employee_advances, over entries whose source_kind is// in advances.SourceKinds and source_id is the advance (read as admin).func (e *Env) ControlBalance(t *testing.T, advanceID uuid.UUID) money.Amount// StartInstance starts a bare instance of kind for source, in its own// transaction — for schema tests that need an instance before the// header API exists (Task 5).func (e *Env) StartInstance(t *testing.T, kind string, source uuid.UUID, reporter uuid.UUID, amount string) workflow.Instance -
Step 1: Write migration 00130.
-- +goose Up
-- ADVANCES (spec 7, contract §2.1). The header. Its workflow state lives-- on the advance instance; the two variance legs are separate instances of-- their own kinds, all with source_id = this row's id.CREATE TABLE advance ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, claimant_member_id UUID NOT NULL, holder_party_id UUID NOT NULL, instance_id UUID NOT NULL, topup_instance_id UUID, refund_instance_id UUID, purpose TEXT NOT NULL CONSTRAINT advance_purpose_present CHECK (btrim(purpose) <> '' AND length(purpose) <= 500), currency TEXT NOT NULL CONSTRAINT advance_currency_code CHECK (currency ~ '^[A-Z]{3}$'), amount NUMERIC(19,4) NOT NULL CONSTRAINT advance_amount_positive CHECK (amount > 0), fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT advance_fx_rate_positive CHECK (fx_rate > 0), fx_rate_source TEXT NOT NULL CONSTRAINT advance_fx_rate_source_known CHECK (fx_rate_source IN ('identity','reference','override')), base_amount NUMERIC(19,4) NOT NULL CONSTRAINT advance_base_positive CHECK (base_amount > 0), due_on DATE NOT NULL, -- The claimant's preferred channel (Ruling A18). Plan 6's table is reached -- only through payout.GetChannel, so there is no FK here. payout_channel_id UUID, revision INT NOT NULL DEFAULT 1 CONSTRAINT advance_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT advance_tenant_id UNIQUE (org_id, id), CONSTRAINT advance_reference_once UNIQUE (org_id, reference), CONSTRAINT advance_instance_once UNIQUE (org_id, instance_id), CONSTRAINT advance_topup_instance_once UNIQUE (org_id, topup_instance_id), CONSTRAINT advance_refund_instance_once UNIQUE (org_id, refund_instance_id), -- Spec 7.3: the variance fans out one way, never two. CONSTRAINT advance_one_variance_leg CHECK (topup_instance_id IS NULL OR refund_instance_id IS NULL), CONSTRAINT advance_purpose_language CHECK (purpose !~ <cjk>), CONSTRAINT advance_claimant_fk FOREIGN KEY (org_id, claimant_member_id) REFERENCES organization_member (org_id, id), CONSTRAINT advance_holder_party_fk FOREIGN KEY (org_id, holder_party_id) REFERENCES party (org_id, id), CONSTRAINT advance_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT advance_topup_instance_fk FOREIGN KEY (org_id, topup_instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT advance_refund_instance_fk FOREIGN KEY (org_id, refund_instance_id) REFERENCES workflow_instance (org_id, id));CREATE INDEX advance_by_claimant ON advance (org_id, claimant_member_id, created_at DESC, id DESC);CREATE INDEX advance_by_created ON advance (org_id, created_at DESC, id DESC);CREATE INDEX advance_by_due ON advance (org_id, due_on);
-- ACQUITTALS (spec 7.3). One per advance: recorded by the claimant while the-- advance sits at `received`, decided (acquitted_at) by the verifier, then-- frozen. The CHECKs are the decision table's database mirror.CREATE TABLE advance_acquittal ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), advance_id UUID NOT NULL, actual_currency TEXT NOT NULL CONSTRAINT advance_acquittal_currency_code CHECK (actual_currency ~ '^[A-Z]{3}$'), actual_amount NUMERIC(19,4) NOT NULL, actual_fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT advance_acquittal_fx_rate_positive CHECK (actual_fx_rate > 0), actual_fx_rate_source TEXT NOT NULL CONSTRAINT advance_acquittal_fx_rate_source_known CHECK (actual_fx_rate_source IN ('identity','reference','override')), actual_base NUMERIC(19,4) NOT NULL, receipt_date DATE, receipt_document_id UUID, category_id UUID, reason TEXT, -- The §6.7 tax record (Ruling A30), with a claim receipt line's rules. document_basis TEXT, supplier_name TEXT, supplier_tin TEXT, einvoice_uuid TEXT, relaxation_reason TEXT, expense_account_id UUID, recorded_by UUID NOT NULL, acquitted_at TIMESTAMPTZ, acquitted_by UUID, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT advance_acquittal_tenant_id UNIQUE (org_id, id), CONSTRAINT advance_acquittal_once UNIQUE (org_id, advance_id), -- Spec 7.3 row 3: actual < 0 is refused; the database mirrors it. CONSTRAINT advance_acquittal_actual_nonnegative CHECK (actual_amount >= 0), CONSTRAINT advance_acquittal_base_nonnegative CHECK (actual_base >= 0), CONSTRAINT advance_acquittal_base_sign CHECK (sign(actual_base) = sign(actual_amount)), -- Spec 7.3 row 1: actual > 0 needs the receipt, a date on it, and the -- category that says which expense account it is charged to. CONSTRAINT advance_acquittal_receipt_required CHECK (actual_amount = 0 OR (receipt_date IS NOT NULL AND receipt_document_id IS NOT NULL AND category_id IS NOT NULL)), -- Spec 7.3 row 2: actual = 0 needs a reason, and there is no receipt. CONSTRAINT advance_acquittal_reason_required CHECK (actual_amount > 0 OR (btrim(coalesce(reason, '')) <> '' AND receipt_date IS NULL AND receipt_document_id IS NULL AND category_id IS NULL)), CONSTRAINT advance_acquittal_nothing_at_identity CHECK (actual_amount > 0 OR (actual_fx_rate = 1 AND actual_fx_rate_source = 'identity')), CONSTRAINT advance_acquittal_decided_pair CHECK ((acquitted_at IS NULL) = (acquitted_by IS NULL)), -- The account is frozen when, and only when, the acquittal is decided and -- something was spent (Ruling A17). CONSTRAINT advance_acquittal_account_frozen CHECK ( (acquitted_at IS NULL AND expense_account_id IS NULL) OR (acquitted_at IS NOT NULL AND (actual_amount > 0) = (expense_account_id IS NOT NULL))), CONSTRAINT advance_acquittal_reason_language CHECK (coalesce(reason, '') !~ <cjk>), -- The tax record, mirroring 00072_expense_claims.sql one for one: -- mirrors expense_claim_line_basis_known. CONSTRAINT advance_acquittal_basis_known CHECK (document_basis IS NULL OR document_basis IN ('einvoice_company','einvoice_employee','foreign_supplier','relaxation_receipt')), -- mirrors expense_claim_line_einvoice_identifiers. CONSTRAINT advance_acquittal_einvoice_identifiers CHECK (document_basis IS NULL OR document_basis NOT IN ('einvoice_company','einvoice_employee') OR (supplier_tin IS NOT NULL AND einvoice_uuid IS NOT NULL)), -- mirrors expense_claim_line_relaxation_reason. CONSTRAINT advance_acquittal_relaxation_reason CHECK (document_basis IS DISTINCT FROM 'relaxation_receipt' OR btrim(coalesce(relaxation_reason, '')) <> ''), -- mirrors expense_claim_line_tin_shape. CONSTRAINT advance_acquittal_tin_shape CHECK (supplier_tin IS NULL OR supplier_tin ~ '^[A-Z]{1,2}[0-9]{8,13}$'), -- mirrors expense_claim_line_einvoice_uuid_shape. CONSTRAINT advance_acquittal_einvoice_uuid_shape CHECK (einvoice_uuid IS NULL OR einvoice_uuid ~ '^[A-Z0-9]{10,64}$'), -- mirrors expense_claim_line_text_language for the tax record's text. CONSTRAINT advance_acquittal_tax_text_language CHECK (coalesce(supplier_name, '') !~ <cjk> AND coalesce(relaxation_reason, '') !~ <cjk>), -- Spec 7.3 row 2 again: nothing spent, no receipt, so no tax record. CONSTRAINT advance_acquittal_nothing_has_no_tax_record CHECK (actual_amount > 0 OR (document_basis IS NULL AND supplier_name IS NULL AND supplier_tin IS NULL AND einvoice_uuid IS NULL AND relaxation_reason IS NULL)), -- Plan 5 R14's completeness half, at the decision: a recognised expense -- names the basis of its evidence. CONSTRAINT advance_acquittal_basis_when_decided CHECK (acquitted_at IS NULL OR actual_amount = 0 OR document_basis IS NOT NULL), CONSTRAINT advance_acquittal_advance_fk FOREIGN KEY (org_id, advance_id) REFERENCES advance (org_id, id), CONSTRAINT advance_acquittal_document_fk FOREIGN KEY (org_id, receipt_document_id) REFERENCES document (org_id, id), CONSTRAINT advance_acquittal_category_fk FOREIGN KEY (org_id, category_id) REFERENCES expense_category (org_id, id), CONSTRAINT advance_acquittal_account_fk FOREIGN KEY (org_id, expense_account_id) REFERENCES account (org_id, id), CONSTRAINT advance_acquittal_recorded_by_fk FOREIGN KEY (org_id, recorded_by) REFERENCES organization_member (org_id, id), CONSTRAINT advance_acquittal_acquitted_by_fk FOREIGN KEY (org_id, acquitted_by) REFERENCES organization_member (org_id, id));-- This consumer's own one-record rule (contract §1.4, Ruling A15).CREATE UNIQUE INDEX advance_acquittal_receipt_once ON advance_acquittal (org_id, receipt_document_id) WHERE receipt_document_id IS NOT NULL;
-- (ENABLE + FORCE ROW LEVEL SECURITY and CREATE POLICY org_isolation with-- USING and WITH CHECK on current_setting('app.current_org', true)::uuid for-- both tables — write each out, as in 00072.)
REVOKE DELETE ON advance, advance_acquittal FROM erp_app;
-- The semantic of the advance's current state, and its two open flags.CREATE FUNCTION advance_state(p_org UUID, p_advance UUID, OUT semantic TEXT, OUT payout_open BOOLEAN, OUT accrual_open BOOLEAN) LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT s.semantic, i.payout_open, i.accrual_open FROM public.advance a JOIN public.workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id JOIN public.workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE a.org_id = p_org AND a.id = p_advance$$;
-- DRAFT-ONLY HEADER EDITS (Plan 5 R16's reasoning, Ruling A12).CREATE FUNCTION advance_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF (SELECT semantic FROM public.advance_state(NEW.org_id, NEW.id)) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'advance % is not a draft: its purpose, amount, due date and channel cannot change', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_is_draft'; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_draft_guard BEFORE UPDATE OF purpose, currency, amount, fx_rate, fx_rate_source, base_amount, due_on, payout_channel_id ON advance FOR EACH ROW EXECUTE FUNCTION advance_draft_guard();
-- THE VARIANCE LEG MATCHES THE ACQUITTAL (Ruling A9). A leg is set once, by-- the acquittal transaction, to an instance of the right kind for this-- advance, and only when the decided acquittal's sign calls for it.CREATE FUNCTION advance_variance_leg_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_actual NUMERIC; v_decided TIMESTAMPTZ; v_kind TEXT; v_source UUID;BEGIN IF TG_OP = 'INSERT' THEN IF NEW.topup_instance_id IS NOT NULL OR NEW.refund_instance_id IS NOT NULL THEN RAISE EXCEPTION 'advance %: a variance leg is set by its acquittal, never at creation', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_matches'; END IF; RETURN NEW; END IF; IF (OLD.topup_instance_id IS NOT NULL AND NEW.topup_instance_id IS DISTINCT FROM OLD.topup_instance_id) OR (OLD.refund_instance_id IS NOT NULL AND NEW.refund_instance_id IS DISTINCT FROM OLD.refund_instance_id) THEN RAISE EXCEPTION 'advance %: a variance leg, once set, never changes', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_fixed'; END IF; SELECT q.actual_base, q.acquitted_at INTO v_actual, v_decided FROM public.advance_acquittal q WHERE q.org_id = NEW.org_id AND q.advance_id = NEW.id; IF NEW.topup_instance_id IS NOT NULL AND NEW.topup_instance_id IS DISTINCT FROM OLD.topup_instance_id THEN SELECT i.kind, i.source_id INTO v_kind, v_source FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = NEW.topup_instance_id; IF v_decided IS NULL OR v_actual <= NEW.base_amount OR v_kind <> 'advance_topup' OR v_source <> NEW.id THEN RAISE EXCEPTION 'advance %: a top-up needs a decided overspend and an advance_topup instance of this advance', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_matches'; END IF; END IF; IF NEW.refund_instance_id IS NOT NULL AND NEW.refund_instance_id IS DISTINCT FROM OLD.refund_instance_id THEN SELECT i.kind, i.source_id INTO v_kind, v_source FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = NEW.refund_instance_id; IF v_decided IS NULL OR v_actual <= 0 OR v_actual >= NEW.base_amount OR v_kind <> 'advance_refund' OR v_source <> NEW.id THEN RAISE EXCEPTION 'advance %: a refund needs a decided underspend above zero and an advance_refund instance of this advance', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'advance_variance_leg_matches'; END IF; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_variance_leg_guard BEFORE INSERT OR UPDATE OF topup_instance_id, refund_instance_id ON advance FOR EACH ROW EXECUTE FUNCTION advance_variance_leg_guard();
-- AN ACQUITTAL IS WRITTEN ONLY AT `received`, AND FROZEN ONCE DECIDED-- (Ruling A16): the advance's state is acknowledged with the payout open and-- no accrual open. The acquittal transaction sets acquitted_at while the-- state is still `received`, before workflow.Transition.CREATE FUNCTION advance_acquittal_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE st RECORD;BEGIN IF TG_OP = 'UPDATE' AND OLD.acquitted_at IS NOT NULL THEN RAISE EXCEPTION 'acquittal of advance % is decided: it never changes', OLD.advance_id USING ERRCODE = '23514', CONSTRAINT = 'advance_acquittal_is_final'; END IF; SELECT * INTO st FROM public.advance_state(NEW.org_id, NEW.advance_id); IF st.semantic IS DISTINCT FROM 'acknowledged' OR st.payout_open IS NOT TRUE OR st.accrual_open IS NOT FALSE THEN RAISE EXCEPTION 'advance % is not awaiting acquittal', NEW.advance_id USING ERRCODE = '23514', CONSTRAINT = 'advance_acquittal_while_received'; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_acquittal_guard BEFORE INSERT OR UPDATE ON advance_acquittal FOR EACH ROW EXECUTE FUNCTION advance_acquittal_guard();
-- +goose DownDROP TABLE advance_acquittal;DROP TABLE advance;DROP FUNCTION advance_acquittal_guard();DROP FUNCTION advance_variance_leg_guard();DROP FUNCTION advance_draft_guard();DROP FUNCTION advance_state(UUID, UUID);- Step 2: Write migration 00131.
-- +goose Up
-- SETTLEMENTS (Ruling A18): one row per payout of an `advance` or-- `advance_topup` instance, frozen. snapshot is payout.Snapshot's sealed-- JSON exactly as returned; Plan 7 never decrypts it. txid ties the row to-- the transaction that paid, which is what the payout rules read.CREATE TABLE advance_settlement ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), advance_id UUID NOT NULL, instance_id UUID NOT NULL, kind TEXT NOT NULL CONSTRAINT advance_settlement_kind_known CHECK (kind IN ('advance','advance_topup')), channel_id UUID NOT NULL, credit_account_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT advance_settlement_amount_positive CHECK (amount > 0), currency TEXT NOT NULL CONSTRAINT advance_settlement_currency_code CHECK (currency ~ '^[A-Z]{3}$'), snapshot JSONB NOT NULL, channel_key TEXT GENERATED ALWAYS AS (snapshot->'channel'->>'key') STORED, account_tail TEXT GENERATED ALWAYS AS (snapshot->>'account_tail') STORED, proof_document_id UUID, settled_by UUID NOT NULL, settled_at TIMESTAMPTZ NOT NULL, txid xid8 NOT NULL DEFAULT pg_current_xact_id(), CONSTRAINT advance_settlement_tenant_id UNIQUE (org_id, id), CONSTRAINT advance_settlement_snapshot_shape CHECK ( jsonb_typeof(snapshot) = 'object' AND snapshot->'version' = '1'::jsonb AND btrim(coalesce(snapshot->'channel'->>'key', '')) <> '' AND jsonb_typeof(snapshot->'fields') = 'array'), CONSTRAINT advance_settlement_masked_not_plain CHECK ( NOT jsonb_path_exists(snapshot, '$.fields[*] ? (@.sensitivity == "masked" && exists(@.value))')), CONSTRAINT advance_settlement_masked_sealed CHECK ( NOT jsonb_path_exists(snapshot, '$.fields[*] ? (@.sensitivity == "masked" && !(exists(@.sealed)))')), CONSTRAINT advance_settlement_advance_fk FOREIGN KEY (org_id, advance_id) REFERENCES advance (org_id, id), CONSTRAINT advance_settlement_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT advance_settlement_account_fk FOREIGN KEY (org_id, credit_account_id) REFERENCES account (org_id, id), CONSTRAINT advance_settlement_proof_fk FOREIGN KEY (org_id, proof_document_id) REFERENCES document (org_id, id), CONSTRAINT advance_settlement_settled_by_fk FOREIGN KEY (org_id, settled_by) REFERENCES organization_member (org_id, id));CREATE INDEX advance_settlement_by_advance ON advance_settlement (org_id, advance_id, settled_at DESC);CREATE INDEX advance_settlement_by_instance ON advance_settlement (org_id, instance_id, settled_at DESC);-- (RLS: ENABLE, FORCE, org_isolation USING + WITH CHECK.)REVOKE UPDATE, DELETE ON advance_settlement FROM erp_app;
-- KEY ROTATION (Plan 5 R45, contract §2.1): the one permitted change is a-- re-encryption of the sealed members. Own copy of Plan 5's normaliser, so-- this table does not depend on another plan's function.GRANT UPDATE (snapshot) ON advance_settlement TO erp_app;
CREATE FUNCTION advance_settlement_unsealed(p JSONB) RETURNS JSONB LANGUAGE sql IMMUTABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT (p - 'fields') || jsonb_build_object('fields', coalesce(( SELECT jsonb_agg((f - 'sealed') || jsonb_build_object('sealed', f ? 'sealed') ORDER BY n) FROM jsonb_array_elements(p->'fields') WITH ORDINALITY AS e(f, n)), '[]'::jsonb))$$;
CREATE FUNCTION advance_settlement_reseal_only() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.advance_settlement_unsealed(NEW.snapshot) IS DISTINCT FROM public.advance_settlement_unsealed(OLD.snapshot) THEN RAISE EXCEPTION 'advance settlement %: only a key-rotation reseal may change a snapshot', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'advance_settlement_reseal_only'; END IF; RETURN NEW;END $$;CREATE TRIGGER advance_settlement_reseal_only BEFORE UPDATE ON advance_settlement FOR EACH ROW EXECUTE FUNCTION advance_settlement_reseal_only();
-- IDEMPOTENCY KEYS (Ruling A23). Operational: prunable.CREATE TABLE advance_idempotency_key ( org_id UUID NOT NULL REFERENCES organization (id), member_id UUID NOT NULL, key TEXT NOT NULL CONSTRAINT advance_idempotency_key_length CHECK (length(key) BETWEEN 1 AND 255), request_hash TEXT NOT NULL CONSTRAINT advance_idempotency_request_hash_hex CHECK (request_hash ~ '^[0-9a-f]{64}$'), status_code INT NOT NULL DEFAULT 0, response JSONB, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT advance_idempotency_pk PRIMARY KEY (org_id, member_id, key), CONSTRAINT advance_idempotency_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id));-- (RLS: ENABLE, FORCE, org_isolation USING + WITH CHECK.)
-- +goose DownDROP TABLE advance_idempotency_key;DROP TABLE advance_settlement;DROP FUNCTION advance_settlement_reseal_only();DROP FUNCTION advance_settlement_unsealed(JSONB);-
Step 3: Retention.
amendable+=advance,advance_acquittal(comment: “Plan 7: the advance and its acquittal (spec 7, 11A.4). Header edited only as a draft and an acquittal only atreceived, both DB-enforced (Rulings A12, A16); decided acquittals frozen; nothing erased.”).appendOnly+=advance_settlement(“Plan 7: what each advance or top-up payout was made against, frozen at settlement. erp_app holds UPDATE on the snapshot column only, for a key-rotation reseal whose trigger forbids any visible change (Ruling A18); has_table_privilege UPDATE is false”).operational+=advance_idempotency_key(“Plan 7: replay protection for transition requests; the decision log and audit trail are the record”).sqlc.yaml: append the twomoney.Rateoverrides.make generate. -
Step 4: Write the fixture —
advancestest/env.go.Newcallsexpensestest.New(t)and wraps it (the blank import ofinternal/platform/advancesis implicit: this package imports it forSourceKinds).NopPoster:posting.ResetForTest(); posting.Register(nopPoster{}); t.Cleanup(posting.ResetForTest)withnopPoster.Postreturning nil.Control:chart.ResolveControl(ctx, tx, advances.ControlKind)ine.Tx.ControlBalance(admin pool, explicit org predicates):
SELECT coalesce(sum(l.base_debit - l.base_credit), 0)::text FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id WHERE l.org_id = $1 AND a.control_kind = 'employee_advances' AND e.source_kind = ANY($2) AND e.source_id = $3- Step 5: Write the failing tests.
advancestest/env_test.go:
func TestTheWorldSeedsTheThreeAdvanceKinds(t *testing.T) { e := advancestest.New(t) // workflow.Latest for "advance", "advance_topup", "advance_refund" each // returns version 1; e.Control(t) is the account coded 1550; the // provisioning readiness for step 60 is done (the kinds are seeded).}schema_test.go (instances via e.StartInstance; header rows inserted as erp_app inside e.Tx; walking an instance via workflow.Transition with e.NopPoster(t), each move its own transaction — wave-3 §5):
func TestAnAdvanceHeaderIsEditableOnlyAsADraft(t *testing.T) { // PRESENCE: in draft, UPDATE advance SET purpose, amount, due_on succeed. // Walk draft -> pending_review (Staff): UPDATE purpose -> 23514 // 'advance_is_draft'; UPDATE revision = revision + 1 still succeeds.}func TestTheAcquittalDecisionTableIsMirroredInTheDatabase(t *testing.T) { // An advance walked to received. PRESENCE: a row with actual 1200, date, // document (e.Document as Staff), category meals inserts; a row with // actual 0 and reason "Trip cancelled" (MYR, rate 1, identity) inserts // (on a second advance). Then, one each, 23514 naming the constraint: // actual -1 -> advance_acquittal_actual_nonnegative; actual 900 without // receipt_date -> advance_acquittal_receipt_required; actual 0 without // reason -> advance_acquittal_reason_required; actual 0 with a document // -> advance_acquittal_reason_required; actual 0 at rate 4.2 -> // advance_acquittal_nothing_at_identity; actual 10 with base 0 -> // advance_acquittal_base_sign; reason "旅行取消" -> // advance_acquittal_reason_language.}func TestTheAcquittalTaxRecordMirrorsTheClaimLinesRules(t *testing.T) { // Ruling A30; each constraint named after the 00072 one it mirrors. // PRESENCE: actual 1200 with basis einvoice_company, TIN "C20880050010" // and uuid "F9D425P6DS7D8IU0000000000A" inserts. Then, one each (23514): // einvoice_company without uuid -> advance_acquittal_einvoice_identifiers; // relaxation_receipt without a reason -> advance_acquittal_relaxation_reason; // TIN "12345" -> advance_acquittal_tin_shape; uuid "abc" -> // advance_acquittal_einvoice_uuid_shape; basis "receipt" -> // advance_acquittal_basis_known; supplier_name "午餐店" -> // advance_acquittal_tax_text_language; actual 0 with a supplier TIN -> // advance_acquittal_nothing_has_no_tax_record; setting acquitted_at on a // 1200 row with no basis -> advance_acquittal_basis_when_decided.}func TestAnAcquittalIsWrittenOnlyWhileReceived(t *testing.T) { // An advance in draft: INSERT -> 'advance_acquittal_while_received'. // Walked to received: INSERT succeeds (PRESENCE). SET acquitted_at, // acquitted_by, expense_account_id: succeeds; any further UPDATE -> // 'advance_acquittal_is_final'.}func TestAReceiptBacksOneAdvance(t *testing.T) // the same document on a second advance's acquittal -> 23505 advance_acquittal_receipt_oncefunc TestAVarianceLegMustMatchItsAcquittal(t *testing.T) { // Advance 1000 at received, acquittal actual 1200 decided (acquitted_at // set). A started "advance_topup" instance for this advance (e.StartInstance): // PRESENCE: UPDATE advance SET topup_instance_id = it succeeds. // Then, one each: setting it again to another instance -> // 'advance_variance_leg_fixed'; on a second advance (actual 700) setting // topup_instance_id -> 'advance_variance_leg_matches'; a refund leg whose // instance is kind advance_topup -> 'advance_variance_leg_matches'; both // legs on one row -> 'advance_one_variance_leg'; an INSERT with a leg set // -> 'advance_variance_leg_matches'; a leg on an advance whose acquittal // is not decided -> 'advance_variance_leg_matches'.}func TestAdvanceRecordsCannotBeDeleted(t *testing.T) // 42501 on advance, advance_acquittal, advance_settlement, after PRESENCE of UPDATE advance.revisionfunc TestAnAdvanceSettlementNeverHoldsAMaskedValueInPlaintext(t *testing.T) { // Plan 5's hand-written version-1 snapshot (a payee_name and a masked, // sealed account_no). PRESENCE: inserts; channel_key and account_tail // generated. Then masked value present -> advance_settlement_masked_not_plain; // masked without sealed -> advance_settlement_masked_sealed; version 2 -> // advance_settlement_snapshot_shape; kind 'advance_refund' -> // advance_settlement_kind_known.}func TestOnlyAResealMayRewriteAnAdvanceSettlement(t *testing.T) { // PRESENCE: replacing only the sealed object succeeds. A display change // -> advance_settlement_reseal_only; UPDATE amount -> 42501; // has_table_privilege UPDATE false, has_column_privilege snapshot true.}func TestAdvanceIdempotencyKeysArePrunable(t *testing.T) // erp_app DELETE succeeds-
Step 6: Run.
make generate;go test ./internal/platform/advances/... ./internal/kernel/db/ -count=1→ PASS (the search-path guard sees the six functions; the constraint-name guard the new names; the tenant-FK guard the composite keys). -
Step 7: Sabotage. Drop the
v_actual <= NEW.base_amountclause fromadvance_variance_leg_guard→TestAVarianceLegMustMatchItsAcquittal’s underspend case goes red; quote; restore. Then drop theOLD.acquitted_at IS NOT NULLbranch fromadvance_acquittal_guard→ the “any further UPDATE” case ofTestAnAcquittalIsWrittenOnlyWhileReceivedgoes red; quote; restore. -
Step 8: Commit.
git add db/migrations/00130_advances.sql db/migrations/00131_advance_settlement.sql sqlc.yaml \ internal/kernel/db/retention_test.go internal/gen/sqlc internal/platform/advancesgit commit -m "Add advance, acquittal and settlement tables with the decision table in the database"Task 5: The advance header — create, read, list, edit the draft, who may see it
Section titled “Task 5: The advance header — create, read, list, edit the draft, who may see it”Waits for Task 1. Needs Task 4.
Files:
- Create:
internal/platform/advances/advance.go,internal/platform/advances/visibility.go,internal/platform/advances/cursor.go,internal/platform/advances/submission.go - Test:
internal/platform/advances/advance_test.go,internal/platform/advances/visibility_test.go,internal/platform/advances/submission_test.go
Interfaces:
-
Consumes:
numbering.Allocate,numbering.Format;workflow.Start,workflow.SetAmount,workflow.Get,workflow.AvailableTransitions,workflow.Decisions;fx.Resolve,fx.NoRateError,fx.SourceIdentity;company.Get;expenses.CheckRecordText(field, s string) error;audit.Write,activity.Record; the cursor encoding ofinternal/platform/expenses/cursor.go(copy it: a module does not share unexported helpers). -
Caller constraint (
workflow/instance.goTransitiondoc comment):workflow.Startandworkflow.SetAmounteach audit, so each runs in a transaction that does not also transition this advance. Create and draft edits are their own transactions. -
Produces:
type Advance struct {ID, InstanceID uuid.UUIDTopupInstanceID, RefundInstanceID *uuid.UUIDReference stringClaimantID, HolderPartyID uuid.UUIDPurpose, Currency stringAmount money.AmountFXRate money.RateFXRateSource stringBaseAmount money.AmountDueOn time.TimePayoutChannelID *uuid.UUIDRevision intState string // the advance instance's state keySemantic posting.Semantic // and its semanticCreatedAt, UpdatedAt time.Time}type AdvanceInput struct {ClaimantID uuid.UUID // the caller (Plan 5 R20: nobody files for someone else)Purpose stringCurrency stringAmount money.AmountDueOn time.TimePayoutChannelID *uuid.UUID}type AdvancePatch struct {Purpose *stringCurrency *stringAmount *money.AmountDueOn *time.TimePayoutChannelID *uuid.UUIDClearPayoutChannel bool}const MaxDueDays = 366func CreateAdvance(ctx context.Context, tx pgx.Tx, c clock.Clock, in AdvanceInput) (Advance, error)func GetAdvance(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Advance, error)func LockAdvance(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Advance, error) // FOR UPDATE OF the advance row onlyfunc UpdateAdvance(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, p AdvancePatch) (Advance, error)type ListFilter struct {Scope string // "mine" (default) or "all" (the handler checks read_all)Member uuid.UUID // the caller, for "mine"Limit int // 1..100, default 50Cursor *Cursor}func ListAdvances(ctx context.Context, tx pgx.Tx, f ListFilter) ([]Advance, *Cursor, error)func ToAct(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID) ([]Advance, error) // ≤ 200, oldest in state firstfunc CanRead(ctx context.Context, tx pgx.Tx, c clock.Clock, a Advance, member uuid.UUID, readAll bool) (bool, error)// InstanceIDs is the advance's instance, then its leg, if any.func (a Advance) InstanceIDs() []uuid.UUID// FieldError is one refused field (Plan 5 R43's detail shape).type FieldError struct{ Field, Rule, Detail string }func (e *FieldError) Error() string // "field=<Field> rule=<Rule>: <Detail>"type SubmissionError struct{ Problems []FieldError }func CheckSubmission(ctx context.Context, tx pgx.Tx, c clock.Clock, a Advance) errortype RevisionMismatchError struct{ Current int }var ErrNotFound, ErrNotClaimant, ErrNotDraft, ErrBadCursor error -
Step 1: Write the failing tests (
e := advancestest.New(t); a USD reference rate of 4.2000 for 2026-09-21 stored withfx.StoreReferencein the test, as Plan 5’s line tests do):
advance_test.go:
func TestAnAdvanceIsFiledWithAReferenceAndAnInstance(t *testing.T) { // CreateAdvance{Staff, "Site visit Kuantan", "MYR", 1000.00, due 2026-10-15}: // Reference "AD-2026-0001"; an "advance" instance in "draft", amount // 1000.0000 base, reporter Staff, source_id = advance id; HolderPartyID = // Staff's party; FXRate 1, FXRateSource "identity", BaseAmount 1000.0000; // Revision 1; one audit row (entity "advance", action "create"); one // activity row (verb "created").}func TestAForeignCurrencyAdvanceFreezesItsBaseAmount(t *testing.T) { // USD 300.00 -> FXRate 4.2000000000, source "reference", BaseAmount // 1260.0000, instance amount 1260.0000. UpdateAdvance{Purpose} leaves the // rate alone; UpdateAdvance{Amount: 310} re-resolves (1302.0000) and the // instance amount follows (workflow.SetAmount). No rate for "JPY" -> // *FieldError{currency, no_rate}.}func TestReferencesAreGaplessAcrossAnAbortedCreate(t *testing.T) { // One create in a transaction that then returns an error (rolled back); // the next create gets AD-2026-0001 (spec 20.1.3).}func TestOnlyTheClaimantEditsTheirDraft(t *testing.T) // PRESENCE Staff edits; Owner -> ErrNotClaimantfunc TestADraftEditNeedsTheCurrentRevision(t *testing.T) // expectedRevision 0 -> *RevisionMismatchError{Current: 1}func TestASubmittedAdvanceCannotBeEdited(t *testing.T) { // Walk draft -> pending_review (e.NopPoster; workflow.Transition in its // own tx). UpdateAdvance -> ErrNotDraft; the DB trigger refuses a raw // UPDATE as well (Task 4).}func TestADueDateMustBeAhead(t *testing.T) { // PRESENCE: due today (2026-09-21) and due today + 366 accepted. Due // 2026-09-20 -> *FieldError{due_on, past}; due today + 367 -> // *FieldError{due_on, too_far}.}func TestPurposeIsRecordText(t *testing.T) // "工地考察" -> *FieldError{purpose, language}; blank -> {purpose, required}; amount 0 -> {amount, positive}func TestListingPagesWithACursor(t *testing.T) // 3 advances; Limit 2 -> 2 and a cursor; next page -> 1 and nil; Scope "mine" hides another member'svisibility_test.go:
func TestWhoMayReadAnAdvance(t *testing.T) { // PRESENCE: Staff (claimant) true; readAll true for Outsider. // Submitted: Owner (can review) true; Outsider false. // After Owner reviews and approves, a member who reviewed keeps the view // (decision log). And a leg: once a top-up exists (Task 8's walk; here // seed it with e.StartInstance + the admin UPDATE the trigger allows), // a member allowed on the leg alone sees the advance.}func TestTheQueueShowsWhatICanDecide(t *testing.T) // Staff submits: ToAct(Owner) has it; ToAct(Staff) emptysubmission_test.go:
func TestACompleteDraftPassesSubmission(t *testing.T) // nilfunc TestAStaleDueDateStopsSubmission(t *testing.T) { // Create with due 2026-09-25; move the fixture clock to 2026-09-26 (a new // clock.Fixed in the call); CheckSubmission -> *SubmissionError with // {due_on, past}.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
CreateAdvance: org from context; validate purpose (strings.TrimSpace, ≤ 500,expenses.CheckRecordText("purpose", …)mapped to*FieldError{purpose, language}), currency shape, amount > 0, due date in[today, today+MaxDueDays];fx.Resolve(currency, base, today)(*fx.NoRateError→{currency, no_rate});n := numbering.Allocate(ctx, tx, "advance", year);id := uuid.New();inst := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: Kind, SourceID: id, Reporter: in.ClaimantID, Amount: base}); partySELECT party_id FROM organization_member WHERE org_id = $1 AND id = $2;INSERT INTO advance (id, org_id, reference, claimant_member_id, holder_party_id, instance_id, purpose, currency, amount, fx_rate, fx_rate_source, base_amount, due_on, payout_channel_id, created_at, updated_at) VALUES (…);audit.Write{EntityKind: EntityAdvance, Action: "create", ActorID: &claimant, After: advance};activity.Record{Subject: EntityAdvance, Verb: "created", Summary: reference + " " + purpose}.- Reads: one
advanceSelectjoiningworkflow_instanceandworkflow_stateread-only forState/Semantic(Plan 5 R40’s pattern), every table tied to$1.LockAdvanceappendsFOR UPDATE OF a. UpdateAdvance:LockAdvance; claimant only;Semantic == posting.DraftelseErrNotDraft; revision; re-validate touched fields; re-resolve FX only when amount or currency changed (A11);UPDATE advance SET …, revision = revision + 1, updated_at = $n; if base changed,workflow.SetAmount(ctx, tx, c, a.InstanceID, base)(locks the instance after the advance row — the plan’s order); auditupdatewith before/after.ListAdvances: keyset on(created_at DESC, id DESC)exactly asexpenses.ListClaims.ToAct: candidates are advances any of whose instances is in a non-terminal, non-draft state, oldestentered_state_atfirst, filtered bymayActoverInstanceIDs(), capped at 200.CanRead: Ruling A22 — claimant;readAll;mayActon any instance (an allowedworkflow.AvailableTransitionsentry); anyworkflow.Decisionsrow on any instance withActororAuthorityMemberequal to the member.CheckSubmission: collects every problem; today onlydue_on(past) — the header CHECKs cover the rest.
-
Step 4: Run.
go test ./internal/platform/advances/ -count=1→ PASS. Sabotage: remove the decision-log branch ofCanRead→ the “keeps the view” case goes red; quote; restore. -
Step 5: Commit —
git commit -m "File, edit, list and read advances with frozen base amounts and gapless references".
Task 6: The acquittal record and the receipt binder
Section titled “Task 6: The acquittal record and the receipt binder”Waits for Task 1 (including row 14: taxrecord must be on the base). Needs Tasks 2, 5. Runs in parallel with Task 7 (disjoint files).
Files:
- Create:
internal/platform/advances/acquittal.go,internal/platform/advances/binder.go - Test:
internal/platform/advances/acquittal_test.go,internal/platform/advances/binder_test.go
Interfaces:
-
Consumes:
LockAdvance,GetAdvance,CanRead(Task 5);DecideAcquittal(Task 2);documents.Lock,documents.Get,documents.StatusVerified,documents.ErrNotFound; the binder API as built (internal/platform/documents/binder.go):documents.Binder,documents.Binding,documents.RegisterBinder,documents.BoundElsewhere,documents.MayRead(ctx, tx, c, documentID, memberID);expenses.GetCategory,expenses.UsableAccount,expenses.ErrNotFound,expenses.CheckRecordText;taxrecordas built (Task 1 row 14 reconciled onphase1-wave3at4f3c48d):taxrecord.Normalize(taxrecord.Record) (taxrecord.Record, error),taxrecord.CheckBasisAgainstCategory(basis *string, employeeNamedDocumentAllowed bool) error(the built name for what this plan calledCheckEmployeeNamed),taxrecord.Record{SupplierTIN, EInvoiceUUID, DocumentBasis, RelaxationReason *string}— noSupplierNamefield:taxrecordowns only the shape-checked identifiers, exactly asexpenseskeeps its ownLineInput.SupplierNameoutside the package (internal/platform/expenses/linerules.go:65) — this plan does the same:SupplierNameisadvances’ own field, trimmed and checked withexpenses.CheckRecordText, not part oftaxrecord.Record— and*taxrecord.Error{Field, Rule, Detail}(the built name for what this plan called*taxrecord.FieldError; same shape, sameError()format);expenses.AttachDocumentandexpenses.ErrDocumentBoundElsewhere(tests only);fx.Resolve;workflow.Get(Instance.PayoutOpen,AccrualOpen);member.Can. -
Produces:
type Acquittal struct {ID, AdvanceID uuid.UUIDActualCurrency stringActualAmount money.AmountActualFXRate money.RateActualFXRateSource stringActualBase money.AmountReceiptDate *time.TimeReceiptDocumentID *uuid.UUIDCategoryID *uuid.UUIDReason *stringSupplierName *string // record-bearing free text (§11A.3), not part of taxrecord.Record (Task 1 row 14)TaxRecord taxrecord.Record // §6.7 (Ruling A30); zero for a zero acquittalExpenseAccountID *uuid.UUID // set when decided (Task 8)RecordedBy uuid.UUIDAcquittedAt *time.TimeAcquittedBy *uuid.UUIDCreatedAt, UpdatedAt time.Time}type AcquittalInput struct {ActualCurrency string // "" means the base currencyActualAmount money.AmountReceiptDate *time.TimeReceiptDocumentID *uuid.UUIDCategoryID *uuid.UUIDReason stringSupplierName string // own field, not taxrecord.Record's (Task 1 row 14)TaxRecord taxrecord.Record // document basis, supplier TIN, e-Invoice UUID, relaxation reason}// RecordAcquittal saves the claimant's acquittal while the advance is at// received, binding its receipt (Rulings A8, A15, A16) and returning the// decision it implies now.func RecordAcquittal(ctx context.Context, tx pgx.Tx, c clock.Clock, advanceID, actor uuid.UUID,expectedRevision int, in AcquittalInput) (Acquittal, Advance, AcquittalDecision, error)func GetAcquittal(ctx context.Context, tx pgx.Tx, advanceID uuid.UUID) (Acquittal, error) // ErrNoAcquittalfunc lockAcquittal(ctx context.Context, tx pgx.Tx, orgID, advanceID uuid.UUID) (Acquittal, error)// Facts assembles DecideAcquittal's input from the stored row.func (q Acquittal) Facts(approved money.Amount, today time.Time) AcquittalFacts// ErrDocumentBoundElsewhere / DocumentBoundElsewhereError: the receipt// already backs another record (contract §1.4) — the shape expenses built.var ErrDocumentBoundElsewhere errortype DocumentBoundElsewhereError struct{ Bindings []documents.Binding }func (e *DocumentBoundElsewhereError) Error() string // ErrDocumentBoundElsewhere's text + ": <kind>, …" — kinds only (contract §8.3)func (e *DocumentBoundElsewhereError) Is(target error) bool // target == ErrDocumentBoundElsewherevar ErrNoAcquittal, ErrNotAcquittable, ErrNotYourDocument, ErrDocumentNotVerified, ErrCategoryUnusable errorbinder.go: an unexportedbinderimplementingdocuments.BinderwithKind() == BinderKind, registered frominit()(documents.RegisterBinder(documentBinder{}), Rulings A15, A33). -
Step 1: Write the failing tests. A helper in
acquittal_test.gowalks an advance of MYR 1,000 toreceived(NopPoster; create; submit as Staff; review, approve, pay as Owner through the bank-transfer channel with Staff’s payout profile —settledoes not exist yet, so seed the payout transition withworkflow.Transitiondirectly, one move per transaction; acknowledge as Staff).doc := e.Document(t, e.Staff, "application/pdf", []byte("receipt")).
func TestRecordingAnAcquittalWithAReceipt(t *testing.T) { // RecordAcquittal{1200.00, receipt 2026-09-20, doc, meals} -> decision // OutcomeTopup, Variance 200.0000, LegAmount 200.0000; the row has // ActualCurrency MYR, rate 1 identity, ActualBase 1200.0000, RecordedBy // Staff, AcquittedAt nil; the advance's Revision moved 1 -> 2; one audit // row (entity "advance_acquittal", action "record", After names the // document id); one activity row (verb "acquittal_recorded").}func TestRecordingAgainReplacesTheRecord(t *testing.T) { // A second RecordAcquittal (revision 2) with actual 950 -> OutcomeRefund; // still one row (advance_acquittal_once); audit Before/After both present.}func TestAnAcquittalAtZeroNeedsAReasonAndNoReceipt(t *testing.T) { // {0, reason "Trip cancelled"} -> OutcomeReturned, Variance -1000.0000. // {0, no reason} -> *AcquittalError{reason, required}; {0, with doc} -> // {receipt_document_id, none_exists}. No row written by the refusals.}func TestAnAcquittalIsRecordedOnlyWhileReceived(t *testing.T) { // PRESENCE at received: ok. A draft advance -> ErrNotAcquittable; an // advance at paid_out (not yet acknowledged) -> ErrNotAcquittable.}func TestOnlyTheClaimantRecords(t *testing.T) // Owner -> ErrNotClaimantfunc TestAReceiptMustBeTheClaimantsAndVerified(t *testing.T) { // A document uploaded by Owner -> ErrNotYourDocument; an unverified // (pending) document of Staff's -> ErrDocumentNotVerified; an unknown id // -> ErrNotFound. PRESENCE first: Staff's verified document is accepted.}func TestAReceiptOnAClaimCannotBackAnAdvance(t *testing.T) { // PRESENCE: a fresh document binds. Then attach doc2 to a Plan 5 claim // line (expenses.CreateClaim + AddLine + AttachDocument as Staff) and // RecordAcquittal with doc2 -> *DocumentBoundElsewhereError whose // Bindings[0].Kind is "expense_claim"; its Error() names the kind and // NOT the claim's reference (contract §8.3).}func TestAReceiptOnAnAdvanceCannotBackAClaim(t *testing.T) { // doc bound to the advance; expenses.AttachDocument(doc) -> // errors.Is(err, expenses.ErrDocumentBoundElsewhere) — the base's // check reaches this plan's binder.}func TestReplacingTheReceiptReleasesTheOld(t *testing.T) { // Record with doc, then with doc2: doc is free — expenses.AttachDocument // of doc onto a claim line succeeds.}func TestAForeignReceiptIsConvertedAtItsDate(t *testing.T) { // USD 300.00 dated 2026-09-19 with a stored reference of 4.2000 for that // day -> ActualFXRate 4.2, source "reference", ActualBase 1260.0000, // decision OutcomeTopup 260.0000.}func TestAnUnusableCategoryIsRefused(t *testing.T) // a category whose account was cleared -> ErrCategoryUnusablefunc TestTheAcquittalCarriesTheTaxRecord(t *testing.T) { // Ruling A30. PRESENCE: basis einvoice_company, supplier "Kedai Makan // Siti", TIN " c20880050010 " and uuid "f9d425p6ds7d8iu0000000000a" are // saved normalized (upper-cased, trimmed) — taxrecord's work, not a copy. // Refusals come back as *taxrecord.Error with Plan 5's codes: // einvoice_company without uuid -> {einvoice_uuid, einvoice_identifiers}; // TIN "12345" -> {supplier_tin, tin_shape}; relaxation_receipt without a // reason -> {relaxation_reason, relaxation_reason}; einvoice_employee on // meals (employee_named_document_allowed false) -> {document_basis, // employee_named_not_allowed}; the same after the category allows it -> // accepted. A zero acquittal with any tax field -> refused ({document_basis // or the field, none_exists}); no row written by any refusal. // Recording with no basis at all is allowed (completeness is checked at // the decision, Task 8).}func TestTheBindingPathLocksTheDocument(t *testing.T) { // Tx A: documents.Lock(doc) and hold (10 s context). Tx B: // RecordAcquittal(doc) must block, not succeed; release A; B completes. // Both goroutines bounded; cleanup in t.Cleanup.}binder_test.go:
func TestTheAdvanceBinderReportsItsRecord(t *testing.T) { // After recording with doc: documents.BoundElsewhere(doc, except // Binding{Kind: "expense_claim", RecordID: uuid.New()}) contains // {Kind "advance", RecordID advance.ID, Reference "AD-2026-0001"}; // with except {Kind "advance", RecordID advance.ID} it is empty.}func TestAnApproverMayReadTheAcquittalReceipt(t *testing.T) { // documents.MayRead(ctx, tx, e.Clock, doc, member). PRESENCE: Staff true // (uploader). Owner — who may take received -> acquitted — true through // this binder. Outsider false; the same Outsider after a role grants // advances.advance.read_all -> true (the binder honours its own // read_all, contract §8.3, Ruling A33). A document bound to no advance // -> false for that read_all holder (read_all opens advances' documents, // not every document).}-
Step 2: Run and watch fail.
-
Step 3: Implement
RecordAcquittal— the binding path, in this order (Ruling A15, contract §7: own record first, then the document, thenBoundElsewhere):a := LockAdvance(advanceID);actor != a.ClaimantID→ErrNotClaimant; revision →*RevisionMismatchError.inst := workflow.Get(a.InstanceID); unlessa.Semantic == posting.Acknowledged && inst.PayoutOpen && !inst.AccrualOpen→ErrNotAcquittable.prev, err := lockAcquittal(org, a.ID)(ErrNoAcquittalis fine: first record).- If
in.ReceiptDocumentID != nil:doc := documents.Lock(…)(documents.ErrNotFound→ErrNotFound);doc.UploadedBy != a.ClaimantID→ErrNotYourDocument;doc.Status != documents.StatusVerified→ErrDocumentNotVerified;bs := documents.BoundElsewhere(ctx, tx, doc.ID, documents.Binding{Kind: BinderKind, RecordID: a.ID});len(bs) > 0→&DocumentBoundElsewhereError{Bindings: bs}. - If
in.CategoryID != nil:cat := expenses.GetCategory;expenses.UsableAccount(ctx, tx, cat)error →ErrCategoryUnusable(wrapped). - Reason: trim; if non-empty
expenses.CheckRecordText("reason", …). 6b. Tax record (Ruling A30): a zero acquittal with any tax field set (includingin.SupplierName) →&AcquittalError{Field: <the first set field>, Rule: "none_exists"}; otherwise trimin.SupplierNameand, if non-empty,expenses.CheckRecordText("supplier_name", …)(own field, nottaxrecord.Record’s — Task 1 row 14), thenrec, err := taxrecord.Normalize(in.TaxRecord)(errors returned as they are —*taxrecord.Error, the built name for what this plan called*taxrecord.FieldError), thentaxrecord.CheckBasisAgainstCategory(rec.DocumentBasis, cat.EmployeeNamedDocumentAllowed)(the built name forCheckEmployeeNamed) when a category is named. Storerecand the trimmedSupplierName. - FX (A11): currency
""→ base.ActualAmount.IsZero()→ base, rate 1, identity. Otherwisefx.Resolve(currency, base, *in.ReceiptDate)when a date is present (a missing date is DecideAcquittal’s refusal, so resolve only when it is set; if absent, pass zero base and let the decision refuse). dec, err := DecideAcquittal(AcquittalFacts{Approved: a.BaseAmount, …, Today: clock.TodayUTC(c)})— refuse before writing.- Write:
INSERT …whenprevis absent, elseUPDATE advance_acquittal SET … , updated_at = $n WHERE org_id = $1 AND advance_id = $2;UPDATE advance SET revision = revision + 1, updated_at = $n WHERE org_id = $1 AND id = $2(not a guarded column). audit.Write{EntityKind: EntityAcquittal, EntityID: row.ID, Action: "record", ActorID: &actor, Before: prev-or-nil, After: row};activity.Record{Subject: EntityAdvance, SubjectID: a.ID, Verb: "acquittal_recorded"}.
Implement the binder (the built
internal/platform/expenses/binder.gois the pattern):BoundTo— every advance the document backs as an acquittal receipt or a settlement proof (contract §7), oneBindingper advance:SELECT a.id, a.reference FROM advance aWHERE a.org_id = $1AND a.id IN (SELECT q.advance_id FROM advance_acquittal q WHERE q.org_id = $1 AND q.receipt_document_id = $2UNIONSELECT s.advance_id FROM advance_settlement s WHERE s.org_id = $1 AND s.proof_document_id = $2)ORDER BY a.referenceMayRead(ctx, tx, c, documentID, memberID): no bound advance → false;member.Can(ctx, tx, memberID, ReadAllPermission)→ true; else for each bound advance,GetAdvancethenCanRead(ctx, tx, c, a, memberID, false); true on the first yes (Ruling A33). -
Step 4: Run.
go test ./internal/platform/advances/ -count=1→ PASS. Sabotage: skip theBoundElsewherecall →TestAReceiptOnAClaimCannotBackAnAdvancered; quote; restore. -
Step 5: Commit —
git commit -m "Record advance acquittals and bind their receipts through the document binder".
Task 7: Settlement — the sealed snapshot of an advance or top-up payout, external channels only
Section titled “Task 7: Settlement — the sealed snapshot of an advance or top-up payout, external channels only”Waits for Task 1. Needs Task 5. Runs in parallel with Task 6.
Files:
- Create:
internal/platform/advances/settlement.go,internal/platform/advances/nosnapshotopen_test.go - Test:
internal/platform/advances/settlement_test.go
Interfaces:
-
Consumes:
payout.GetChannel(ctx, tx, id) (payout.Channel, error),payout.Snapshot(ctx, tx, channelID, memberID, claimType string, amount money.Amount, currency string) (json.RawMessage, error),payout.SettlementSnapshot,payout.Names,payout.BehaviourPettyCash;documents.Get; tests:e.Channel,e.SeedChannel(t, payouttest.ChannelRow{…}),e.PayoutProfile,e.CreditAccount(fromexpensestest). -
Produces:
type Settlement struct {ID, AdvanceID, InstanceID, ChannelID, CreditAccountID uuid.UUIDKind string // Kind or KindTopupAmount money.AmountCurrency stringSnapshot json.RawMessage // sealed, byte-for-byte; never logged, audited or servedChannelKey stringAccountTail *stringProofDocumentID *uuid.UUIDSettledBy uuid.UUIDSettledAt time.Time}type SettlementView struct {ID, ChannelID uuid.UUIDKind stringChannelKey, ChannelName stringAccountTail *stringAmount money.AmountCurrency stringFields []SettlementFieldViewProofDocumentID *uuid.UUIDSettledBy uuid.UUIDSettledAt time.Time}type SettlementFieldView struct {Key stringLabel payout.NamesDisplay string}func (s Settlement) View() (SettlementView, error) // unmarshal only; never Value, never Sealedfunc Settlements(ctx context.Context, tx pgx.Tx, advanceID uuid.UUID) ([]Settlement, error) // newest first, both kindsfunc SettlementInThisTx(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID) (Settlement, error) // ErrNoSettlementtype settleInput struct {Advance AdvanceKind string // Kind or KindTopup: the claim-type key (Ruling A18)InstanceID uuid.UUIDAmount money.Amount // baseChannelID *uuid.UUID // the payer's choice; nil falls back to Advance.PayoutChannelIDProofDocumentID *uuid.UUIDActor uuid.UUID}func settle(ctx context.Context, tx pgx.Tx, c clock.Clock, in settleInput) (*Settlement, error)var ErrChannelRequired, ErrChannelInactive, ErrChannelPettyCash, ErrChannelCurrency,ErrChannelClaimType, ErrChannelMaxAmount, ErrProofRequired, ErrProofNotVerified, ErrNoSettlement error -
Step 1: Write the failing tests (internal tests calling
settleinside onee.Tx; a draft advance of MYR 1,000 is enough — the FK needs only the instance;ch := e.Channel(t, "bank_transfer");e.PayoutProfile(t, e.Staff, ch, map[string]string{"payee_name": "Siti Aminah", "bank": "maybank", "account_no": "5140 1234 6789"})):
func TestSettlingAnAdvanceFreezesTheSealedSnapshot(t *testing.T) { // settle{Kind: "advance", Amount 1000, ChannelID &ch, Actor Owner}: one // row; CreditAccountID == e.CreditAccount(ch); Kind "advance"; // InstanceID the advance's; ChannelKey "bank_transfer"; *AccountTail // "6789". The stored bytes contain neither "514012346789" nor // "5140 1234 6789"; unmarshalled, account_no has Sealed != nil and // Value "". SettlementInThisTx(instance) in the same tx returns it.}func TestAPettyCashChannelIsRefused(t *testing.T) { // PRESENCE: bank_transfer settles. e.Channel(t, "petty_cash") -> // ErrChannelPettyCash (Ruling A18, contract §2.1); no row.}func TestTheWorkflowKindIsTheClaimType(t *testing.T) { // A seeded channel with AllowedClaimTypes {"advance"}: Kind "advance" // settles; Kind "advance_topup" -> ErrChannelClaimType. With // {"expense_claim"}: "advance" -> ErrChannelClaimType.}func TestTheOtherChannelChecks(t *testing.T) { // One case each, PRESENCE first: inactive -> ErrChannelInactive; MaxAmount // below the amount -> ErrChannelMaxAmount; a channel row whose // AllowedCurrencies is {"USD"} (e.SeedChannel) -> ErrChannelCurrency; // RequiresSettlementProof // without proof -> ErrProofRequired; a pending proof -> ErrProofNotVerified; // no channel anywhere -> ErrChannelRequired; the advance's preferred // channel is used when the input names none.}func TestPlan6sRefusalsPassThroughTyped(t *testing.T) // a claimant with no profile -> errors.Is(err, payout.ErrNoProfile)func TestASettlementLeavesThisPackageMasked(t *testing.T) { // Settlements(advance)[0].View(): Kind, ChannelKey, AccountTail "6789", // fields payee_name/bank/account_no with displays; json.Marshal(view) // contains neither the full number nor "\"sealed\"" nor "\"value\"".}func TestASettlementIsVisibleOnlyToItsOwnTransaction(t *testing.T) // SettlementInThisTx in a later tx -> ErrNoSettlement; Settlements lists itnosnapshotopen_test.go:
// TestPlan7NeverOpensASnapshot: Plan 7 renders masked displays only (Ruling A18).func TestPlan7NeverOpensASnapshot(t *testing.T) { var scanned int for _, dir := range []string{"../../platform/advances", "../../modules/advances"} { // walk *.go (tests too, skipping this file); count files; fail // naming file:line on any occurrence of "OpenSnapshot". A missing // modules/advances directory (before Task 14) is skipped, not failed. } require.Greater(t, scanned, 5, "PRESENCE: the walk found the package's files")}-
Step 2: Run and watch fail.
-
Step 3: Implement
settle—internal/platform/expenses/settlement.go’ssettlewith these differences: channel from input, elseAdvance.PayoutChannelID, elseErrChannelRequired;ch.SettlementBehaviour == payout.BehaviourPettyCash→ErrChannelPettyCashbeforepayout.Snapshot; claim typein.Kind; currency = the base currency (company.Get), amountin.Amount;payout.Snapshot(ctx, tx, ch.ID, in.Advance.ClaimantID, in.Kind, in.Amount, base), error wrapped with%wand nothing else;INSERT INTO advance_settlement (org_id, advance_id, instance_id, kind, channel_id, credit_account_id, amount, currency, snapshot, proof_document_id, settled_by, settled_at) VALUES (…, $n::jsonb, …) RETURNING id, channel_key, account_tail. No audit here: the transition’s audit row namessettlement_id(Task 8).SettlementInThisTx:… WHERE org_id = $1 AND instance_id = $2 AND txid = pg_current_xact_id() ORDER BY settled_at DESC LIMIT 1.View:json.Unmarshalintopayout.SettlementSnapshot, copy channel key/name, account tail and each field’s key, label, display. -
Step 4: Run. PASS. Sabotage: delete the petty-cash branch →
TestAPettyCashChannelIsRefusedred (a row is written crediting cash in hand); quote; restore. -
Step 5: Commit —
git commit -m "Freeze sealed payout snapshots for advances and top-ups; refuse petty cash".
Task 8: The transition service — branch by sign, start the leg, require the overspend approval
Section titled “Task 8: The transition service — branch by sign, start the leg, require the overspend approval”Waits for Task 1. Needs Tasks 5, 6, 7.
Files:
- Create:
internal/platform/advances/transition.go,internal/platform/advances/overspend.go,internal/platform/advances/available.go - Modify:
internal/platform/advances/advancestest/env.go(walk helpers below) - Test:
internal/platform/advances/transition_test.go,internal/platform/advances/overspend_test.go,internal/platform/advances/available_test.go
Interfaces:
-
Consumes:
LockAdvance,GetAdvance,CheckSubmission(5);lockAcquittal,Acquittal.Facts(6);settle(7);DecideAcquittal(2);expenses.GetCategory,expenses.UsableAccount;workflow.Get,workflow.Load,workflow.Transition,workflow.Start,workflow.AvailableTransitions,workflow.Decisions,workflow.ErrStaleState,workflow.RefuseNotRoutable,workflow.Refusal,Instance.PayoutOpen. -
Caller constraint (wave-2 §3, wave-3 §5;
workflow/instance.goTransitiondoc): nothing in the transaction audits beforeworkflow.Transition;workflow.Startof the leg comes after it (Ruling A21). -
Produces:
type TransitionInput struct {AdvanceID uuid.UUIDKind string // Kind (default when ""), KindTopup or KindRefund: which instance movesFrom, To stringActor uuid.UUIDReason stringPayoutChannelID *uuid.UUID // entering a payout state of advance or advance_topupProofDocumentID *uuid.UUID}// Transition moves one of the advance's instances along one edge. The// caller's transaction must not commit on error; the handler maps InTx's// error through ledger.MapError.func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Advance, workflow.Decision, error)var ErrNoSuchLeg, ErrAcquittalBranch, ErrOverspendNotApproved error// OverspendApproved is Ruling A10, pure: decisions oldest first; draftKey// is the leg's draft state key; reporter the claimant.func OverspendApproved(ds []workflow.Decision, draftKey string, reporter uuid.UUID) boolconst RefuseAcquittalBranch = "acquittal_branch"// Available is workflow.AvailableTransitions for one of the advance's// instances, with the acquittal exit that does not match the recorded// acquittal marked not allowed (Ruling A9).func Available(ctx context.Context, tx pgx.Tx, c clock.Clock, a Advance, kind string, actor uuid.UUID) ([]workflow.Available, error)package advancestest // additionstype Step struct{ Kind, From, To string; Actor uuid.UUID; Reason string; Channel *uuid.UUID }// Move runs one advances.Transition in its own transaction.func (e *Env) Move(t *testing.T, id uuid.UUID, s Step) advances.Advance// Received files MYR amount for Staff (due 2026-10-15) and walks it// submit → review → approve → pay (bank_transfer; Staff's profile saved) →// acknowledge, one transaction per move.func (e *Env) Received(t *testing.T, amount string) advances.Advance// Record saves an acquittal of actual (MYR, receipt dated 2026-09-20, a// fresh verified document, category "meals", tax record: basis// einvoice_company, supplier "Kedai Makan Siti", TIN "C20880050010",// uuid "F9D425P6DS7D8IU0000000000A"); actual "0" records the reason// "Trip cancelled" and no tax record instead.func (e *Env) Record(t *testing.T, a advances.Advance, actual string) advances.Advance -
Step 1: Write the failing tests.
overspend_test.go (pure):
func TestOverspendApproved(t *testing.T) { // reporter R, approver A; decision(from, to, gate, actor, authority). // Table: // none -> false // draft->topup_pending (reporter, R) -> false // + topup_pending->topup_approved (target, A, A) -> true // + an approval whose authority is R (delegated to A) -> false // submit, approve(A), reject(advance, A), revise(R), // submit(R) -> false (reset on leaving draft) // … then approve(A) again -> true // an approval by R itself (target, R, R) -> false}transition_test.go (e.NopPoster(t) — the posting rules arrive in Tasks 10–11; these tests prove the advance side only):
func TestSubmittingChecksTheAdvanceThenTheWorkflow(t *testing.T) { // PRESENCE: draft -> pending_review (Staff) ok; one audit row (entity // "advance", action "transition", After {kind, state, decision_id}); // activity verb "submitted". A draft with a stale due date -> // *SubmissionError, still draft, no decision.}func TestPayingFreezesASettlementBeforeTheEngineMoves(t *testing.T) { // approved -> paid_out (Owner, bank_transfer): one settlement row with // Kind "advance" and Amount 1000.0000; the audit row's After names // settlement_id; activity verb "paid". With the petty_cash channel -> // ErrChannelPettyCash, still approved, no settlement, no decision.}func TestAuthorityIsCheckedBeforeEvidence(t *testing.T) { // At received with NO acquittal recorded: Staff tries received -> // acquitted -> *workflow.RefusedError (self-approval / not granted), NOT // ErrNoAcquittal (Plan 5 R21's order: a non-approver learns nothing). // Owner -> ErrNoAcquittal.}func TestTheAcquittalPicksTheBranch(t *testing.T) { // One advance of 1000 per row, walked to received and recorded at actual: // 1000 -> acquitted ok, no leg; returned -> ErrAcquittalBranch // 1200 -> acquitted ok, TopupInstanceID set: an "advance_topup" instance // in "draft", amount 200.0000, reporter Staff, source = advance // 700 -> acquitted ok, RefundInstanceID set: "advance_refund" in draft, 300.0000 // 0 -> acquitted -> ErrAcquittalBranch; returned ok, no leg // Each refusal leaves the advance at received with no decision row.}func TestAnAcquittalWithoutATaxBasisCannotBeDecided(t *testing.T) { // Ruling A30's completeness half. PRESENCE: recorded with basis // einvoice_company (TIN, uuid) -> received -> acquitted ok. Another // advance recorded at 1200 with no basis -> *AcquittalError // {document_basis, required} (advances' own completeness check — taxrecord // as built has no Complete function, Task 1 row 14); still received; no // decision. A zero // acquittal needs none and returns fine.}func TestTheAcquittalFreezesTheRecordAndItsAccount(t *testing.T) { // After received -> acquitted (1200): AcquittedAt set, AcquittedBy Owner, // ExpenseAccountID == meals' account; RecordAcquittal now -> // ErrNotAcquittable (and the DB trigger would refuse too). // After received -> returned (0): AcquittedAt set, ExpenseAccountID nil.}func TestTheLegStartsInTheAcquittalTransactionAndMovesInALaterOne(t *testing.T) { // The acquittal tx's audit rows, in seq order: workflow_instance // "transition" (the advance), workflow_instance "start" (the leg), // advance "transition" — nothing audited before the engine moved. // A later tx: Staff moves the leg draft -> topup_pending (Kind // "advance_topup") ok.}func TestATopupIsPaidOnlyAfterTheExcessIsApproved(t *testing.T) { // The 1200 advance's leg: submit (Staff); approve_excess (Owner); pay // (Owner, bank_transfer): a settlement row Kind "advance_topup", Amount // 200.0000, InstanceID the leg's. (The ABSENCE is OverspendApproved's // table above, and the sabotage below.)}func TestATopupRefusesPettyCash(t *testing.T) // topup_approved -> topup_paid with petty_cash -> ErrChannelPettyCashfunc TestASettlementProofCountsAsBound(t *testing.T) { // Contract §7. Pay advance A through a seeded channel that requires proof, // with proof P (a verified document Staff uploaded, for the test's sake): // documents.BoundElsewhere(P, except {expense_claim, uuid.New()}) names A. // A second advance B settles with the same P (settling never calls // BoundElsewhere — one slip may cover several payments). B's acquittal // cannot use P as its receipt: *DocumentBoundElsewhereError naming A.}func TestAnUnknownLegIsRefused(t *testing.T) // Kind "advance_topup" on an advance with no top-up -> ErrNoSuchLeg; Kind "nonsense" -> ErrNoSuchLegfunc TestTheAdvanceLockIsTakenBeforeTheInstanceLock(t *testing.T) { // Tx A: LockAdvance and hold (10 s). Tx B: Transition(submit) blocks, does // not deadlock and does not succeed; release A; B completes. Bounded.}available_test.go:
func TestTheMismatchedAcquittalExitIsNotAllowed(t *testing.T) { // At received, recorded at 1200, as Owner: Available lists acquitted // (allowed) and returned (allowed false, Refusal.Code "acquittal_branch"). // Recorded at 0: the reverse. With no acquittal recorded: both exits not // allowed with "acquittal_branch" for the owner, and the claimant's view // keeps the engine's own refusals untouched.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
overspend.go:
func OverspendApproved(ds []workflow.Decision, draftKey string, reporter uuid.UUID) bool { approved := false for _, d := range ds { if d.From == draftKey { approved = false // every departure from draft starts a new request } if d.Gate == workflow.GateTarget && d.Actor != reporter && d.AuthorityMember != reporter { approved = true } } return approved}transition.go — Transition, in this order (Ruling A21, the Global Constraints’ lock order):
a := LockAdvance(in.AdvanceID).- Pick the instance:
Kind/""→a.InstanceID;KindTopup→*a.TopupInstanceID;KindRefund→*a.RefundInstanceID; nil or unknown →ErrNoSuchLeg. inst := workflow.Get(no lock),def := workflow.Load(inst.DefinitionID),from, _ := def.State(in.From),to, known := def.State(in.To).examine := known && inst.StateKey == in.From && mayTake(…)(copyexpenses’mayTake, including theRefuseNotRoutableallowance).- Classify:
submission := known && from.Semantic == posting.Draft && to.Semantic != posting.Draft && !reversing(to);payout := known && to.Semantic == posting.Payout;acquittal := kind == Kind && known && from.Semantic == posting.Acknowledged && inst.PayoutOpen && !inst.AccrualOpen && (to.Semantic == posting.Accrual || to.Semantic == posting.Cancelled). - When
examine:submission && kind == Kind→CheckSubmission(a).payout && kind == KindTopup→ds := workflow.Decisions(inst.ID);!OverspendApproved(ds, draftKeyOf(def), a.ClaimantID)→ErrOverspendNotApproved.payout && kind == KindRefund→ nothing (no channel; Ruling A19).payout && (kind == Kind || kind == KindTopup)→s, err := settle(ctx, tx, c, settleInput{Advance: a, Kind: kind, InstanceID: inst.ID, Amount: inst.Amount, …}).acquittal→q := lockAcquittal(org, a.ID)(ErrNoAcquittal);dec := DecideAcquittal(q.Facts(a.BaseAmount, today));(to.Semantic == posting.Accrual) == (dec.Outcome == OutcomeReturned)→ErrAcquittalBranch; for a non-zero acquittal re-resolve the account (expenses.GetCategory+UsableAccount, elseErrCategoryUnusable), and check the tax record is complete.taxrecord(as built, Task 1 row 14) has noCompletefunction — its own doc comment says completeness is each caller’s business at submission time — so this step isadvances’ own unexported check, intransition.go:checkTaxRecordComplete(rec taxrecord.Record) error { if rec.DocumentBasis == nil { return &AcquittalError{Field: "document_basis", Rule: "required", Detail: "…"} }; return nil }, called only whendec.Outcome != OutcomeReturned; thentaxrecord.CheckBasisAgainstCategory(rec.DocumentBasis, …)against the category as it stands now (Ruling A30; both refusals are returned as*taxrecord.Error/*AcquittalErrorrespectively — mapped the same way at the HTTP layer, Task 15);UPDATE advance_acquittal SET acquitted_at = $3, acquitted_by = $4, expense_account_id = $5, updated_at = $3 WHERE org_id = $1 AND advance_id = $2(stillreceived, so the trigger allows it).
d := workflow.Transition(…{InstanceID: inst.ID, From, To, Actor, Reason}).- Stale guard (Plan 5’s 5b):
!examine && (submission || payout || acquittal)→workflow.ErrStaleState. - Leg (A9, A21): if
acquittal && to.Semantic == posting.Accrual && dec.Outcome ∈ {Topup, Refund}→leg := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: KindTopup|KindRefund, SourceID: a.ID, Reporter: a.ClaimantID, Amount: dec.LegAmount});UPDATE advance SET topup_instance_id|refund_instance_id = $3 WHERE org_id = $1 AND id = $2(the trigger checks the match). a2 := GetAdvance;audit.Write{EntityKind: EntityAdvance, EntityID: a.ID, Action: "transition", ActorID: &in.Actor, Before: {kind, state}, After: {kind, state, decision_id, settlement_id, leg_instance_id}};activity.Recordwith verbsubmitted(leaving the advance’s draft),paid(entering any payout),acquitted(entering the accrual),returned(enteringreturned), elsetransitioned.
available.go: workflow.AvailableTransitions for the chosen instance; if kind == Kind and the instance is at an acknowledged state with the payout open and no accrual, look up the acquittal (absent ⇒ both acquittal exits get the refusal) and the outcome, and for each entry whose destination is an accrual or cancelled state set Allowed = false, Refusal = &workflow.Refusal{Code: RefuseAcquittalBranch} when it does not match. The claimant’s entries whose Allowed is already false keep the engine’s refusal.
-
Step 4: Run.
go test ./internal/platform/advances/... -count=1→ PASS. -
Step 5: Sabotage. (a) Remove the
OverspendApprovedcheck and publish, in the test’s org, a v2advance_topupgraph whosedraft → topup_paidis a target edge (OWNER seat) — a top-up started after that publication pays with no sign-off: the check’s absence is visible only there, so addTestAnOrgGraphWithoutSignOffCannotPayAnOverspend(publish viaworkflow.PublishGraph; start a fresh advance’s leg; pay →ErrOverspendNotApproved; PRESENCE: the seeded version pays after approval), watch it red with the check removed, quote, restore. (b) Swap the branch condition’s equality →TestTheAcquittalPicksTheBranchred; quote; restore. -
Step 6: Commit —
git commit -m "Move advances through their workflows: branch by variance sign, start the leg, require sign-off on excess".
Task 9: Reseal advance settlements after a payout-key rotation
Section titled “Task 9: Reseal advance settlements after a payout-key rotation”Waits for Task 1. Needs Tasks 4, 7, 8 (its fixtures pay advances through advancestest.Received). Runs in parallel with Task 10 (disjoint files).
Files:
- Create:
internal/platform/advances/reseal.go,cmd/migrate/reseal_advances_test.go - Modify:
cmd/migrate/reseal.go(insert the advance lines; rewrite none of Plan 5’s),docs/DEPLOY_NOTES.md(a Plan 7 section on top) - Test:
internal/platform/advances/reseal_test.go
Interfaces:
-
Consumes:
payout.ResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error),payout.SettlementSnapshot,payout.ErrUnknownKey;payouttest.NewKey,payouttest.UseKeyring(tests); Task 4’s column grant andadvance_settlement_reseal_only;cmd/migrate’srunReseal,orgReseal,resealReport(Plan 5 Task 21). -
Produces:
package advances// ResealSettlements re-encrypts, under activeKeyID, up to limit of the// calling org's advance settlement snapshots holding a value sealed under// another key. Rows locked in id order by one SELECT … FOR UPDATE before// the first audit.Write; a row is updated and audited (EntitySettlement,// "reseal", key ids only) only when payout.ResealSnapshot reports a change.func ResealSettlements(ctx context.Context, tx pgx.Tx, c clock.Clock, activeKeyID string, limit int) (n int, more bool, err error)func PendingReseal(ctx context.Context, tx pgx.Tx, activeKeyID string) (int, error)package main // cmd/migrate/reseal.go — inserted// orgReseal gains: AdvanceSettlementsResealed, AdvanceSettlementsPending int// resealReport.Pending() adds AdvanceSettlementsPending. -
Step 1: Write the failing platform tests —
reseal_test.go(two advances walked withe.Received(t, "1000"), which pays throughbank_transferwith Staff’s profile, so two snapshots under"test-1"; the no-op poster):
func TestResealMovesEveryAdvanceSnapshotToTheActiveKey(t *testing.T) { // k2 := payouttest.NewKey(t, "test-2"); payouttest.UseKeyring(t, k2, &e.PayoutKey). // PRESENCE: PendingReseal("test-2") == 2. ResealSettlements(…, 100) -> 2, // false; PendingReseal == 0; every sealed kid "test-2"; View() unchanged; // amount, credit_account_id, kind, txid unchanged; two audit rows // (entity "advance_settlement", action "reseal", key ids only; no "6789", // "ct", "nonce" or payee name in their JSON).}func TestAdvanceResealIsIdempotentAndResumable(t *testing.T) { // limit 1: 1,true; 1,true; 0,false — exactly two audit rows; a second full // run writes nothing more.}func TestAdvanceResealTouchesOnlyTheCallingOrg(t *testing.T) // e.Another(t) with its own paid advance keeps PendingReseal 1- Step 2: Run and watch fail. Step 3: Implement
reseal.go— exactlyinternal/platform/expenses/reseal.go’sResealSettlements,PendingResealandsealedKeyIDswithexpense_claim_settlementreplaced byadvance_settlement, the error prefixadvances:andEntitySettlement. The pending predicate reads only plaintextkidmembers:
const pendingPredicate = `jsonb_path_exists(s.snapshot, '$.fields[*].sealed.kid ? (@ != $kid)', jsonb_build_object('kid', $2::text))`-
Step 4: Run. PASS. Sabotage: drop the
if !changed { continue }guard and the pending predicate → the idempotency test red; quote; restore. Commit —git commit -m "Reseal advance settlement snapshots under the active payout key". -
Step 5: Write the failing command test —
cmd/migrate/reseal_advances_test.go(e := advancestest.New(t),e2 := e.Another(t),e.NopPoster(t), oneReceivedadvance in each org;app, _ := db.Open(ctx, e.H.AppDSN, eventbus.New())):
func TestResealCommandCoversAdvanceSettlements(t *testing.T) { // Rotate to "test-2". PRESENCE: runReseal(check=true) reports, per org, // AdvanceSettlementsPending 1 alongside Plan 5's counts, and Pending() // includes them. runReseal(check=false, batch=1): every pending count 0; // under k2 alone the advance snapshot still opens with payout.OpenSnapshot // (test-only, cmd/migrate is outside the A18 guard's directories).}-
Step 6: Insert into
cmd/migrate/reseal.go. InorgReseal, add the two fields; inPending(), add+ o.AdvanceSettlementsPending; inrunReseal, directly after Plan 5’s settlements loop, a second loop of the same shape callingadvances.ResealSettlements(ctx, tx, c, activeKeyID, batch)accumulating intor.AdvanceSettlementsResealed(error wrapped"reseal-payout-details: org %s: advance settlements: %w"), and inside the pending transactionr.AdvanceSettlementsPending, err = advances.PendingReseal(ctx, tx, activeKeyID); in the per-org print line, append, advance settlements resealed M (pending Q). Importinternal/platform/advances. Touch nothing else. -
Step 7: Deploy note — a new top section in
docs/DEPLOY_NOTES.md:
## Plan 7 — advances
- **Existing organizations get the three advance workflows by re-running provisioning** (`POST /v1/org/provision`, or `orgadmin provision`): step 60 publishes `advance`, `advance_topup` and `advance_refund` at v1 for any organization that has no version of them. New organizations get them at creation. No new environment variable.- **Key rotation:** `migrate reseal-payout-details` now also reseals advance settlement snapshots (`advance_settlement`), and `--check` exits 0 only when those are done too. The procedure in Plan 5's section is unchanged; the claims-table check has an advance twin: `SELECT count(*) FROM advance_settlement WHERE jsonb_path_exists(snapshot, '$.fields[*].sealed.kid ? (@ == "<old kid>")');` → `0`.- **Acquittal reminders are not sent.** `GET /v1/advances/reminders` lists overdue acquittals and who should be told; nothing emails or notifies anyone yet (Plan 7 Ruling A28).- Step 8: Run and commit.
go test ./cmd/migrate/ ./internal/platform/advances/ -count=1→ PASS.git add internal/platform/advances/reseal*.go cmd/migrate/reseal.go cmd/migrate/reseal_advances_test.go docs/DEPLOY_NOTES.md && git commit -m "Reseal advance settlements in migrate reseal-payout-details".
Task 10: Posting rules — (advance, payout) and (advance, accrual)
Section titled “Task 10: Posting rules — (advance, payout) and (advance, accrual)”Waits for Task 1. Needs Task 8.
Files:
- Create:
internal/platform/advances/posting.go(Rules, shared helpers),internal/platform/advances/posting_advance.go - Modify:
internal/platform/advances/advancestest/env.go(addUseAdvancePoster) - Test:
internal/platform/advances/posting_advance_test.go
Interfaces:
-
Consumes:
accounting.PostingRule,accounting.Source,accounting.Draft;ledger.Unreversed,ledger.Get,ledger.Line,ledger.SemanticPayout,ledger.Post(test),ledger.List(test);chart.ResolveControl,chart.ListJournals,chart.JournalBank,chart.JournalPurchase;GetAdvance,GetAcquittal,SettlementInThisTx;payout.SettlementSnapshot,payout.BehaviourPettyCash;workflow.Get,workflow.Decisions;documents.Get. -
Produces:
// Rules is every posting rule this package owns; modules/advances// registers them at boot. Task 11 appends TopupPayoutRule{}, RefundPayoutRule{}.func Rules() []accounting.PostingRuletype AdvancePayoutRule struct{} // (advance, payout): Dr Employee Advances / Cr the settled channel's accounttype AdvanceAccrualRule struct{} // (advance, accrual): Dr the acquittal's expense account / Cr the payout's control, at actual// Each: Kind() string; Semantic() posting.Semantic; Build(ctx, tx, src accounting.Source) (accounting.Draft, error)var ErrInstanceMismatch, ErrSettlementMismatch, ErrNotPaidOut, ErrNotAcquitted, ErrNothingToAccrue, ErrPettyCashDeferred error// payoutAccounts reads the advance's one unreversed payout entry: the// account it debited (the control, Ruling A20) and the account it// credited (the bank, Ruling A19). ErrNotPaidOut when there is none.func payoutAccounts(ctx context.Context, tx pgx.Tx, a Advance) (control, bank uuid.UUID, err error)// journalFor is the active journal whose default account is accountID,// else the default journal of fallbackType (Plan 5 R26's choice).func journalFor(ctx context.Context, tx pgx.Tx, accountID uuid.UUID, fallbackType string) (*uuid.UUID, string, error)package advancestest // additionfunc (e *Env) UseAdvancePoster(t *testing.T) // e.Env.UsePoster(t, advances.Rules()...) -
Step 1: Write the failing tests (
e.UseAdvancePoster(t)right afterNew):
func TestPayingOutAnAdvanceDebitsTheControl(t *testing.T) { // e.Received(t, "1000"): one journal_entry for the advance instance with // semantic payout, source_kind "advance", source_id advance.ID: Dr 1550 // 1000.0000 (party = Staff's party, memo "AD-2026-0001 advance") / Cr // e.CreditAccount(bank_transfer) 1000.0000; journal BNK (the journal // fronting 1520), else the default bank journal. Nothing posted at // submission, review, approval or acknowledgement. // e.ControlBalance(advance) == 1000.0000.}func TestAnAdvancePayoutReadsOnlyItsOwnSettlement(t *testing.T) // Build with a payout Source in a tx that wrote no settlement -> ErrNoSettlementfunc TestAnAdvancePayoutMustEqualTheAdvance(t *testing.T) { // PRESENCE: Build after settle(amount 1000) returns a Draft. With a // settlement of 999 inserted directly -> ErrSettlementMismatch.}func TestAPettyCashSnapshotIsNeverPostedForAnAdvance(t *testing.T) { // Ruling A18, the second refusal. In ONE tx, insert a settlement row // directly (erp_app) whose hand-built version-1 snapshot says // "settlement_behaviour":"external" -> Build returns a Draft (PRESENCE); // rolled back; the same with "petty_cash" and the petty_cash channel's // credit account -> ErrPettyCashDeferred, no Draft.}func TestAcquittalAccruesTheActualAgainstTheControl(t *testing.T) { // e.Record(t, a, "1200"); received -> acquitted (Owner): one entry, // semantic accrual, source_kind "advance": Dr <meals' account> 1200.0000 // (party Staff, memo "AD-2026-0001 acquittal 2026-09-20") / Cr 1550 // 1200.0000; journal PUR. ControlBalance == -200.0000.}func TestTheAccrualFollowsThePayoutsControlAccount(t *testing.T) { // Ruling A20. After the payout, retire 1550 and create a new // employee_advances control (admin tx through chart): the acquittal // still credits 1550; 1550's balance for the advance is -200.0000 and // the new account's is 0.}func TestAZeroAccrualCouldNeverPost(t *testing.T) { // Ruling A4 as a fact of Plan 4's ledger: PRESENCE — ledger.Post of a // two-line accrual entry of 0.0001 posts; the same entry at 0 is refused // by ledger.Post (the line rule at ledger/post.go:234). This is why a // zero acquittal takes `returned`.}func TestReturningInFullReversesThePayout(t *testing.T) { // e.Record(t, a, "0"); received -> returned (Owner, reason): one reversal // entry mirroring the payout (Dr <bank_transfer's account> 1000.0000 / Cr // 1550 1000.0000), source_kind "advance", source_id advance.ID; // ControlBalance == 0; no accrual entry; no leg.}func TestAnUpheldDisputeBeforeAcquittalReversesThePayout(t *testing.T) { // received -> disputed (Staff) -> rejected (Owner, uphold): the payout is // mirrored (contract §1.1 PastPayout); ControlBalance 0. rejected -> draft // -> … -> paid_out again: a second payout posts (the first is reversed, // Plan 4 R8) and a second settlement row exists; ControlBalance 1000.0000.}func TestEveryAdvancePostingIsTraceable(t *testing.T) { // Spec 11A.7. From the payout entry: source_id -> the advance (its // reference and purpose); workflow.Decisions names Owner as the approver // of `approved` and the payer of `paid_out`; Settlements has the row the // payout was made against. From the accrual entry: the acquittal's // receipt document resolves through documents.Get (status verified, // sha256 recorded); the decision into `acquitted` names Owner.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
AdvancePayoutRule.Build:a := GetAdvance(src.Event.SourceID);a.InstanceID != src.Event.InstanceID→ErrInstanceMismatch;s := SettlementInThisTx(src.Event.InstanceID); unmarshals.Snapshotintopayout.SettlementSnapshot;Channel.SettlementBehaviour == payout.BehaviourPettyCash→ErrPettyCashDeferred(before anything else is read);inst := workflow.Get;s.Amount,a.BaseAmount,inst.AmountpairwiseCmpequal, elseErrSettlementMismatch;control := chart.ResolveControl(ctx, tx, ControlKind)(a payout state names no control kind, sosrc.Controlis nil here);party := a.HolderPartyID;jid, jtype := journalFor(s.CreditAccountID, chart.JournalBank); lines{AccountID: control.ID, Debit: s.Amount, PartyID: &party, Memo: a.Reference + " advance"},{AccountID: s.CreditAccountID, Credit: s.Amount, PartyID: &party, Memo: …};Draft{JournalID: jid, JournalType: jtype, Currency: src.BaseCurrency, Description: "Advance " + a.Reference + " " + a.Purpose}.AdvanceAccrualRule.Build: advance and instance match;q := GetAcquittal(a.ID);q.AcquittedAt == nil→ErrNotAcquitted(Task 8 froze it earlier in this transaction);!q.ActualBase.IsPositive() || q.ExpenseAccountID == nil→ErrNothingToAccrue;control, _ := payoutAccounts(a); lines Dr*q.ExpenseAccountIDq.ActualBase(memoa.Reference + " acquittal " + receipt date), Crcontrolq.ActualBase, both with the holder’s party;JournalType: chart.JournalPurchase;Description: "Acquittal of advance " + a.Reference.payoutAccounts:ledger.Unreversed(a.InstanceID); exactly one header withSemantic == ledger.SemanticPayout, elseErrNotPaidOut;ledger.Getfor its lines; the one line withDebit.IsPositive()is the control, the one withCredit.IsPositive()the bank.journalFor:chart.ListJournals; the first active journal withDefaultAccountID != nil && *DefaultAccountID == accountID→(&j.ID, ""); else(nil, fallbackType).Rules()returns[]accounting.PostingRule{AdvancePayoutRule{}, AdvanceAccrualRule{}}.
-
Step 4: Run. PASS. Sabotage: make the accrual credit
src.Control.IDinstead ofpayoutAccounts’ control →TestTheAccrualFollowsThePayoutsControlAccountred; quote; restore. -
Step 5: Commit —
git commit -m "Post advance payouts and acquittals through the engine, traceable to receipt and approver".
Task 11: Posting rules — (advance_topup, payout) and (advance_refund, payout)
Section titled “Task 11: Posting rules — (advance_topup, payout) and (advance_refund, payout)”Waits for Task 1. Needs Task 10. Runs in parallel with Task 12 (disjoint files).
Files:
- Create:
internal/platform/advances/posting_variance.go - Modify:
internal/platform/advances/posting.go(append the two rules toRules()) - Test:
internal/platform/advances/posting_variance_test.go
Interfaces:
-
Consumes:
payoutAccounts,journalFor(10);GetAdvance,GetAcquittal,SettlementInThisTx;workflow.Get. -
Produces:
type TopupPayoutRule struct{} // (advance_topup, payout): Dr the payout's control / Cr the top-up's settled channel account, at +variancetype RefundPayoutRule struct{} // (advance_refund, payout): Dr the account the payout credited / Cr the payout's control, at -variance// Rules() now returns the four rules. -
Step 1: Write the failing tests (
e.UseAdvancePoster(t)):
func TestAnOverspendNetsTheControlToZero(t *testing.T) { // Spec 7.4 exactly: Received 1000 -> ControlBalance 1000.0000; Record // 1200 and acquit -> -200.0000; the leg: submit (Staff), approve_excess // (Owner), pay (Owner, bank_transfer) -> an entry, semantic payout, // source_kind "advance_topup", source_id = the ADVANCE id: Dr 1550 // 200.0000 / Cr <bank_transfer's account> 200.0000 -> ControlBalance // 0.0000. acknowledge (Staff) posts nothing.}func TestAnUnderspendRefundNetsTheControlToZero(t *testing.T) { // Received 1000; Record 700 and acquit -> ControlBalance 300.0000; the // leg: declare (Staff), confirm (Owner) -> an entry, source_kind // "advance_refund", source_id the advance id: Dr <the account the // payout credited> 300.0000 / Cr 1550 300.0000, journal BNK -> // ControlBalance 0.0000.}func TestATopupPayoutMustEqualTheVariance(t *testing.T) { // PRESENCE: Build after the real settlement returns a Draft. A settlement // row of 150 inserted directly in the paying tx -> ErrSettlementMismatch.}func TestATopupRefusesAPettyCashSnapshot(t *testing.T) // as Task 10's twin -> ErrPettyCashDeferredfunc TestTheRefundReturnsToTheAccountThePayoutLeft(t *testing.T) { // Pay the advance through a second seeded external channel crediting a // different bank account (e.SeedChannel with a second bank journal's // account): the refund debits THAT account (Ruling A19).}func TestAnUpheldTopupDisputeReversesOnlyTheTopup(t *testing.T) { // topup_paid -> disputed (Staff) -> rejected (Owner): one reversal of the // top-up entry only; the advance's payout and accrual stay; ControlBalance // back to -200.0000. rejected -> draft -> submit -> approve -> pay again: // ControlBalance 0.0000 (the first top-up is reversed, Plan 4 R8).}func TestEveryVariancePostingIsTraceable(t *testing.T) { // Top-up entry: source_id -> the advance -> its acquittal's verified // receipt; the leg's decisions name Owner for approve_excess (the // sign-off, wave-3 §5) and pay. Refund entry: the acquittal (receipt and // actual 700) and the decision into refund_confirmed naming Owner.}func TestEveryAdvancePostingCarriesTheAdvanceAsSource(t *testing.T) { // Over the overspend and underspend stories: every journal_entry whose // instance is one of the advance's instances has source_id = advance id // and source_kind in advances.SourceKinds (contract §2.1).}-
Step 2: Run and watch fail. Step 3: Implement.
TopupPayoutRule.Build:a := GetAdvance(src.Event.SourceID);a.TopupInstanceID == nil || *a.TopupInstanceID != src.Event.InstanceID→ErrInstanceMismatch;s := SettlementInThisTx(src.Event.InstanceID); petty-cash snapshot →ErrPettyCashDeferred;q := GetAcquittal;v := q.ActualBase.Sub(a.BaseAmount);v,s.Amount,inst.Amountequal, elseErrSettlementMismatch;control, _ := payoutAccounts(a); Drcontrol/ Crs.CreditAccountID, holder’s party;journalFor(s.CreditAccountID, chart.JournalBank);Description: "Top-up of advance " + a.Reference.RefundPayoutRule.Build: refund-instance match;q;r := a.BaseAmount.Sub(q.ActualBase);requalsinst.Amountand is positive, elseErrSettlementMismatch;control, bank := payoutAccounts(a); Drbank/ Crcontrol;journalFor(bank, chart.JournalBank);Description: "Refund of advance " + a.Reference.
-
Step 4: Run. PASS. Sabotage: make
RefundPayoutRulecreditbankand debitcontrol→TestAnUnderspendRefundNetsTheControlToZerored (600.0000); quote; restore. -
Step 5: Commit —
git commit -m "Post advance top-ups and refunds so the control nets to zero".
Task 12: ListForAging for Plan 9, and the reminder computation
Section titled “Task 12: ListForAging for Plan 9, and the reminder computation”Waits for Task 1. Needs Task 10 (payout entries date the issue). Runs in parallel with Task 11.
Files:
- Create:
internal/platform/advances/aging.go,internal/platform/advances/reminders.go - Test:
internal/platform/advances/aging_test.go,internal/platform/advances/reminders_test.go
Interfaces:
-
Consumes:
orgstruct.SuperiorOf(ctx, tx, memberID, on) ([]uuid.UUID, error); tablesadvance,party,journal_entry,workflow_instance(payout_open,accrual_open),workflow_state. -
Produces (Plan 9 D1’s names and fields, verbatim):
// AgingRef is what an aging report needs about an advance besides money.type AgingRef struct {ID uuid.UUIDReference string // "AD-2026-0007"HolderPartyID uuid.UUIDHolderName string // record-language legal nameIssuedOn time.Time // the payout's entry dateDueOn time.Time // the acquittal due date (reminders use the same date)}// ListForAging returns every advance issued on or before asOf, in the calling org (Ruling A27).func ListForAging(ctx context.Context, tx pgx.Tx, asOf time.Time) ([]AgingRef, error)const (LevelClaimant = "claimant"LevelManager = "manager"ManagerEscalationAfterDays = 7)type Reminder struct {AdvanceID uuid.UUIDReference stringClaimantMemberID uuid.UUIDDueOn time.TimeDaysOverdue intLevel string // LevelClaimant or LevelManagerRecipients []uuid.UUID // the claimant; plus their superiors at manager levelManagerUnresolved bool // manager level, and SuperiorOf found nobody}// EscalationFor is pure: days past due at asOf and the level (Level "" when not overdue).func EscalationFor(dueOn, asOf time.Time) (days int, level string)// Overdue lists unacquitted advances past due at asOf (Ruling A28), oldest due first.func Overdue(ctx context.Context, tx pgx.Tx, asOf time.Time) ([]Reminder, error) -
Step 1: Write the failing tests (
e.UseAdvancePoster(t); the fixture clock is 2026-09-21):
aging_test.go:
func TestAgingRefHasPlan9sShape(t *testing.T) { // reflect over AgingRef: exactly the fields ID uuid.UUID, Reference // string, HolderPartyID uuid.UUID, HolderName string, IssuedOn time.Time, // DueOn time.Time, in that order (Plan 9 D1).}func TestListForAgingListsIssuedAdvances(t *testing.T) { // PRESENCE: a Received advance (paid 2026-09-21, due 2026-10-15) is // listed with Reference, HolderPartyID, HolderName = Staff's legal name, // IssuedOn 2026-09-21, DueOn 2026-10-15. A draft advance and an approved // but unpaid one are not. asOf 2026-09-20 -> empty.}func TestListForAgingKeepsTheFirstIssueDate(t *testing.T) { // Pay on 2026-09-21; dispute upheld (reversal) and re-paid on a clock of // 2026-09-25: IssuedOn stays 2026-09-21; still listed (Plan 9 drops zero // balances itself).}func TestListForAgingIsOrgScoped(t *testing.T) // e.Another's paid advance is not in e's list; PRESENCE: it is in its ownreminders_test.go:
func TestEscalationFor(t *testing.T) { // due 2026-09-21: asOf 09-21 -> 0,""; 09-22 -> 1,claimant; 09-28 -> 7,claimant; // 09-29 -> 8,manager; 2027-01-01 -> 102,manager.}func TestOverdueListsUnacquittedAdvancesPastDue(t *testing.T) { // A Received advance due 2026-10-01. Overdue(2026-10-01) empty (PRESENCE of // the row is the next line); Overdue(2026-10-02) -> one Reminder, // DaysOverdue 1, Level claimant, Recipients [Staff]; // Overdue(2026-10-09) -> Level manager, Recipients [Staff, Owner].}func TestSettledAdvancesAreNotOverdue(t *testing.T) { // PRESENCE: all four are overdue at received. Then one acquitted, one // returned, one rejected via an upheld dispute, and one never paid (draft // with a past due date via admin UPDATE): Overdue lists none of them; // a recorded-but-undecided acquittal still counts as overdue.}func TestAReminderWithNoManagerSaysSo(t *testing.T) { // A claimant seated on a root position (e.Member with nil reportsTo) at // manager level: Recipients [claimant], ManagerUnresolved true.}- Step 2: Run and watch fail. Step 3: Implement.
ListForAging:
SELECT a.id, a.reference, a.holder_party_id, p.legal_name, min(e.entry_date), a.due_on FROM advance a JOIN party p ON p.org_id = a.org_id AND p.id = a.holder_party_id JOIN journal_entry e ON e.org_id = a.org_id AND e.instance_id = a.instance_id AND e.semantic = 'payout' AND e.source_kind = 'advance' WHERE a.org_id = $1 AND p.org_id = $1 AND e.org_id = $1 AND e.entry_date <= $2 GROUP BY a.id, a.reference, a.holder_party_id, p.legal_name, a.due_on ORDER BY min(e.entry_date), a.referenceOverdue:
SELECT a.id, a.reference, a.claimant_member_id, a.due_on FROM advance a JOIN workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE a.org_id = $1 AND i.org_id = $1 AND s.org_id = $1 AND i.payout_open AND NOT i.accrual_open AND s.semantic NOT IN ('rejected', 'cancelled') AND a.due_on < $2 ORDER BY a.due_on, a.referencethen per row EscalationFor; at manager level orgstruct.SuperiorOf(ctx, tx, claimant, asOf), appended after the claimant (deduplicated), ManagerUnresolved when empty. EscalationFor: days := int(asOf.Sub(dueOn).Hours() / 24) on midnight-UTC dates; days <= 0 → (0, ""); days <= ManagerEscalationAfterDays → claimant; else manager.
-
Step 4: Run. PASS. Sabotage: remove
AND NOT i.accrual_open→TestSettledAdvancesAreNotOverduered (the acquitted one listed); quote; restore. -
Step 5: Commit —
git commit -m "Expose advances for aging and compute overdue acquittals with an escalation level".
Task 13: Reconciliation — advance_control and advance_closed_nets_zero
Section titled “Task 13: Reconciliation — advance_control and advance_closed_nets_zero”Waits for Task 1. Needs Tasks 10, 11.
Files:
- Create:
internal/platform/advances/reconcile.go - Test:
internal/platform/advances/reconcile_test.go
Interfaces:
-
Consumes:
reconcile.Check,reconcile.Finding{Check, Ref, Detail},reconcile.Register,reconcile.Default,reconcile.NewSet(whatever constructorreconcile.goexports for a test-built set — Plan 5’sChecks()pattern);tenancy.OrgFromContext;ledger.Post(tests); tables as below. -
Produces:
const (CheckControl = "advance_control"CheckClosedNetsZero = "advance_closed_nets_zero")func Checks() []reconcile.Check // registered from init() -
Step 1: Write the failing tests (
e.UseAdvancePoster(t); a helper builds the healthy book: one draft, one Received (paid), one overspend acquitted with its top-up approved but unpaid, one overspend fully topped up, one underspend fully refunded, one returned, one exact):
func TestTheAdvanceChecksPassOnAHealthyLedger(t *testing.T) { // reconcile.Default().Run over the healthy book lists advance_control and // advance_closed_nets_zero, both with no findings.}func TestAnAdvanceThatDisagreesWithTheLedgerIsNamed(t *testing.T) { // PRESENCE: passes. Admin UPDATE workflow_instance SET payout_open = false // on the Received advance's instance: advance_control reports one Finding, // Ref = the advance id, Detail naming expected 0.0000 and ledger 1000.0000.}func TestAControlLineFromAnotherSourceIsNamed(t *testing.T) { // PRESENCE: passes. ledger.Post (directly, in a test tx) an accrual entry // with SourceKind "expense_claim" and a real claim instance, crediting // 1550 50.00 against an expense account: advance_control reports a // Finding whose Ref is the entry id and whose Detail says the Employee // Advances control carries a line not posted by an advance.}func TestAClosedAdvanceThatDoesNotNetToZeroIsNamed(t *testing.T) { // PRESENCE: passes. ledger.Post an extra entry with SourceKind // "advance_topup", SourceID = the fully topped-up advance, its top-up // instance id, Dr 1550 50.00 / Cr bank: advance_closed_nets_zero reports // Ref = that advance with "50.0000"; advance_control reports it too.}func TestTheChecksSumAcrossTheThreeKinds(t *testing.T) { // The overspend-topped-up and underspend-refunded advances have lines of // two kinds each; both checks pass only because they sum over // SourceKinds (see the sabotage).}- Step 2: Run and watch fail. Step 3: Implement (read-only, no locks, explicit org predicates,
init()registration exactly asinternal/platform/expenses/reconcile.go).
advance_control — per advance:
WITH ledger AS ( SELECT e.source_id, sum(l.base_debit - l.base_credit) AS base FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account ac ON ac.org_id = l.org_id AND ac.id = l.account_id WHERE l.org_id = $1 AND e.org_id = $1 AND ac.org_id = $1 AND ac.control_kind = 'employee_advances' AND e.source_kind = ANY($2) GROUP BY e.source_id)SELECT a.id, a.base_amount::text, i.payout_open, i.accrual_open, coalesce(q.actual_base, 0)::text, coalesce(t.amount, 0)::text, coalesce(t.payout_open, false), coalesce(r.amount, 0)::text, coalesce(r.payout_open, false), coalesce(g.base, 0)::text FROM advance a JOIN workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id LEFT JOIN advance_acquittal q ON q.org_id = a.org_id AND q.advance_id = a.id AND q.org_id = $1 LEFT JOIN workflow_instance t ON t.org_id = a.org_id AND t.id = a.topup_instance_id AND t.org_id = $1 LEFT JOIN workflow_instance r ON r.org_id = a.org_id AND r.id = a.refund_instance_id AND r.org_id = $1 LEFT JOIN ledger g ON g.source_id = a.id WHERE a.org_id = $1 AND i.org_id = $1Expected in Go (money.Amount, Ruling A26): base·payout_open − actual·accrual_open + topup·topup_open − refund·refund_open; unequal → Finding{Check: CheckControl, Ref: advance id, Detail: "expected <e> on Employee Advances, ledger has <g>"}. Then the attribution query — every control line whose entry’s source_kind is not in SourceKinds, or whose source_id is no advance:
SELECT DISTINCT e.id, e.source_kind FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account ac ON ac.org_id = l.org_id AND ac.id = l.account_id WHERE l.org_id = $1 AND e.org_id = $1 AND ac.org_id = $1 AND ac.control_kind = 'employee_advances' AND (e.source_kind IS NULL OR NOT (e.source_kind = ANY($2)) OR NOT EXISTS (SELECT 1 FROM advance a WHERE a.org_id = $1 AND a.id = e.source_id))(each → Finding{Ref: entry id, Detail: "Employee Advances carries a line not posted by an advance (source_kind <k>)"}).
advance_closed_nets_zero — closed = the advance’s state is terminal and its leg, if any, is terminal:
SELECT a.id, coalesce(g.base, 0)::text FROM advance a JOIN workflow_instance i ON i.org_id = a.org_id AND i.id = a.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key LEFT JOIN workflow_instance li ON li.org_id = a.org_id AND li.id = coalesce(a.topup_instance_id, a.refund_instance_id) AND li.org_id = $1 LEFT JOIN workflow_state ls ON ls.org_id = li.org_id AND ls.definition_id = li.definition_id AND ls.state_key = li.state_key AND ls.org_id = $1 LEFT JOIN ledger g ON g.source_id = a.id -- the same CTE as above WHERE a.org_id = $1 AND i.org_id = $1 AND s.org_id = $1 AND s.is_terminal AND (li.id IS NULL OR ls.is_terminal) AND coalesce(g.base, 0) <> 0(each → Finding{Check: CheckClosedNetsZero, Ref: advance id, Detail: "closed advance leaves <g> on Employee Advances"}).
-
Step 4: Run. PASS. Sabotage: replace
= ANY($2)with= 'advance'in the ledger CTE →TestTheChecksSumAcrossTheThreeKindsred; quote; restore. -
Step 5: Commit —
git commit -m "Reconcile Employee Advances per advance and prove closed advances net to zero".
Task 14: modules/advances — composition, permissions, rules at boot, the advance routes
Section titled “Task 14: modules/advances — composition, permissions, rules at boot, the advance routes”Waits for Task 1. Needs Tasks 5, 11, 13 (boot registers advances.Rules(), which must hold all four rules; the checks and binder register from the platform package this links).
Files:
- Create:
internal/modules/advances/permissions.go,routes.go,boot.go,http.go,errors.go,advance_handlers.go,view.go - Modify:
constants.yaml(append four permissions) →make generate;api/openapi.yaml(append paths and schemas) →make generate;internal/app/app.go,internal/app/routes_test.go(composedModules),cmd/api/main.go,cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go,cmd/migrate/wiring_test.go,cmd/orgadmin/wiring_test.go,cmd/worker/wiring_test.go - Test:
internal/modules/advances/routes_test.go,boot_test.go,advance_handlers_test.go
Interfaces:
- Consumes: Tasks 5–8, 12 (
CreateAdvance,GetAdvance,UpdateAdvance,ListAdvances,ToAct,CanRead,GetAcquittal,Settlements,Settlement.View,Available,DecideAcquittal/Acquittal.Facts,EscalationFor);workflow.Decisions,workflow.Get;accounting.RegisterPostingRule;member.RegisterPermission,member.Can;httpx.Protected; the helpers ofinternal/modules/expenses/http.go(copy:decodeStrict,parseID,parseDate,actor,ifMatch,setETag,firstErr— a module does not import another module). - Produces:
Routes in this task (underpackage advances // internal/modules/advancesconst (PermissionAdvanceCreate = "advances.advance.create"PermissionAdvanceRead = "advances.advance.read"PermissionAdvanceReadAll = platformadvances.ReadAllPermission // "advances.advance.read_all"PermissionAdvanceTransition = "advances.advance.transition")type Deps struct {DB *db.DBClock clock.Clock}func Register(g *gin.RouterGroup, deps Deps)
/v1):
| Method | Path | Permission |
|---|---|---|
| GET | /advances |
advances.advance.read (?scope=mine|to_act|all; all also needs read_all) |
| POST | /advances |
advances.advance.create |
| GET | /advances/:id |
advances.advance.read |
| PATCH | /advances/:id |
advances.advance.create (If-Match) |
The advance read shape — the body of GET /advances/:id and of every advance mutation’s response (Tasks 14, 15), with ETag: "<revision>":
{ "advance": { "id", "reference", "claimant_member_id", "holder_party_id", "purpose", "currency", "amount", "fx_rate", "fx_rate_source", "base_amount", "due_on", "payout_channel_id", "revision", "state", "semantic" }, "acquittal": { "actual_currency", "actual_amount", "actual_fx_rate", "actual_fx_rate_source", "actual_base", "receipt_date", "receipt_document_id", "category_id", "reason", "document_basis", "supplier_name", "supplier_tin", "einvoice_uuid", "relaxation_reason", "outcome", "variance", "leg_amount", "expense_account_id", "recorded_by", "acquitted_at", "acquitted_by" } | null, "legs": [ { "kind", "instance_id", "state", "semantic", "amount", "available_transitions": [ { "to", "action_key", "gate", "requires_reason", "allowed", "labels", "refusal" } ], "decisions": [ { "from", "to", "actor_member_id", "authority_member_id", "via", "reason", "decided_at" } ] } ], "settlements": [ { "id", "kind", "channel_id", "channel_key", "channel_name", "account_tail", "amount", "currency", "fields": [ { "key", "label", "display" } ], "proof_document_id", "settled_by", "settled_at" } ], "overdue": { "days_overdue", "level" } | null }legs[0] is always the advance’s own instance (kind: "advance"), then its leg if any. available_transitions come from advances.Available (Ruling A9’s acquittal_branch refusal included). acquittal.outcome/variance/leg_amount are DecideAcquittal over the stored row (null when it refuses). settlements are built from Settlement.View() only: no snapshot, value or sealed member anywhere, and the OpenAPI schema has additionalProperties: false. overdue is EscalationFor(due_on, today) when the advance’s payout is open and its accrual is not.
- Step 1: Write the failing tests.
routes_test.go:TestEveryRouteRequiresItsExactPermissionin the shape ofinternal/modules/expenses/routes_test.go— the table above, the four permission strings pinned, and gin’s table under/v1/advancesequal to it (Task 15 extends it).boot_test.go:TestLinkingTheModuleRegistersTheFourPostingRules(accounting.DefaultRules().Lookupfinds(advance, payout),(advance, accrual),(advance_topup, payout),(advance_refund, payout));TestLinkingTheModuleRegistersTheChecksAndTheBinder(reconcile.Default().Runon a fresh org listsadvance_controlandadvance_closed_nets_zero;documents.BoundElsewhereon a fresh document returns no error and a document bound to an advance names kindadvance);TestLinkingTheModuleSeedsTheThreeKinds(workflow.SeededGraphscontains them).advance_handlers_test.go(the env pattern ofinternal/modules/expenses/claim_handlers_test.go: a provisioned org viaadvancestest,httpx.NewRouter, this module alone, members with minted tokens — Staff (create, read, transition), Owner (all four), Outsider (read only)):
func TestAnAdvanceIsFiledEditedAndReadWithItsETag(t *testing.T) { // POST /v1/advances {purpose, currency "MYR", amount "1000.00", due_on} // -> 201, ETag "1", reference AD-2026-0001, legs[0].state "draft", the // submit transition allowed for Staff. PATCH with If-Match "1" -> 200, // ETag "2". PATCH with If-Match "1" -> 409 naming revision 2. Without // If-Match -> 400.}func TestMoneyIsAStringBothWays(t *testing.T) // "amount": 1000 (a JSON number) -> 400 naming "amount"; response amounts are stringsfunc TestAFieldRefusalNamesItsFieldAndRule(t *testing.T) // due_on yesterday -> 422 whose detail starts "field=due_on rule=past:"func TestAnAdvanceNobodyShouldSeeIsNotFound(t *testing.T) // PRESENCE Staff 200; Outsider 404; Outsider with read_all 200func TestScopeAllNeedsReadAll(t *testing.T) // ?scope=all as Staff -> 403; as Owner -> 200func TestTheQueueShowsWhatICanDecide(t *testing.T) // ?scope=to_act as Owner after Staff submits -> it; as Staff -> emptyfunc TestAPaidAdvanceShowsOnlyMaskedPayoutDetails(t *testing.T) { // advancestest Received (in-process): GET as Staff and as Owner — // settlements[0].account_tail "6789", account_no display "****6789"; // the raw body contains neither the full number nor "sealed" nor // "snapshot" nor "value".}func TestTheReadShapeShowsTheLegAndTheOverdueBlock(t *testing.T) { // A Received advance due 2026-09-20 read on 2026-09-21: overdue // {days_overdue 1, level "claimant"}. After recording 1200 and acquitting // (in-process): acquittal.outcome "topup", variance "200.0000"; legs[1] // kind "advance_topup", state "draft"; overdue null.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
permissions.go:init()registers the four with descriptions (advances.advance.transition: “Use the advance transition endpoint; authority to decide still comes from the workflow’s grants”).constants.yaml: append the four after the lastexpenses.*line.boot.go:init()—for _, r := range platformadvances.Rules() { accounting.RegisterPostingRule(r) }. The graphs, checks and binder register frominternal/platform/advances’s owninit(), which this import links.advance_handlers.go: each handler oneInTx;ifMatchon PATCH; reads build the shape inview.go; visibility after loading:CanRead(…, member.Can(ctx, tx, caller, PermissionAdvanceReadAll))false → 404.errors.gomapErr(extended by Task 15):ErrNotFound404;ErrNotClaimant403;*RevisionMismatchError409 (“the advance is at revision N; reload and retry”);ErrNotDraft409;*FieldError,*SubmissionError422 (Ruling R43 shape, problems joined with"; ");ErrBadCursor422; a Postgres refusal onadvance_is_draft→ 409 (the database said what Go should have).- Composition:
app.Deps.Advances advances.Deps;app.Routercallsadvances.Register(api, deps.Advances)afterexpenses.Register;composedModules["advances"]lists the four keys.cmd/api/main.go: import the module,Advances: advances.Deps{DB: database, Clock: clock.System()}.cmd/migrate,cmd/orgadmin,cmd/worker: blank-importinternal/modules/advances(permissions, rules, graphs, checks, binder); theirwiring_test.gogain the four permission keys (the worker must link the posting rules — Plan 4 R31 — or a transition it ever runs would failNoPostingRuleError).internal/app/binaries_test.gothen sees the module in all three ofcmd/{api,migrate,orgadmin}. api/openapi.yaml: append the four operations withAdvance,AdvanceInput,AdvancePatch,AdvanceView,AdvanceAcquittalView,AdvanceLeg,AdvanceSettlementView(additionalProperties: false),AdvanceOverdue; money fields$ref: '#/components/schemas/Money', ratesRate, the workflow shapes by$refto the existingAvailableTransitionandDecisionschemas if Plan 5 declared them (else declareAdvanceAvailable/AdvanceDecisionhere).
-
Step 4: Run.
make generate && go test ./internal/modules/advances/ ./internal/app/ ./cmd/... -count=1→ PASS. -
Step 5: Commit —
git commit -m "Compose the advances module: permissions, posting rules, advance routes".
Task 15: The acquittal, transition and reminder routes
Section titled “Task 15: The acquittal, transition and reminder routes”Waits for Task 1 (row 14). Needs Tasks 6, 8, 12, 14.
Files:
- Create:
internal/platform/advances/idempotency.go,internal/modules/advances/acquittal_handlers.go,transition_handlers.go,reminder_handlers.go - Modify:
internal/modules/advances/routes.go,routes_test.go,errors.go,api/openapi.yaml→make generate - Test:
internal/platform/advances/idempotency_test.go,internal/modules/advances/acquittal_handlers_test.go,transition_handlers_test.go,reminder_handlers_test.go
Interfaces:
- Consumes:
RecordAcquittal(6),Transition(8),Overdue(12), the read shape (14);ledger.MapError; the error types of Tasks 5–8, 10–11; Plan 6’s typed refusals asinternal/modules/expenses/transition_handlers.go’smapPayoutErrlists them. - Produces:
Routes (underpackage advances // platform, copied from internal/platform/expenses/idempotency.go onto advance_idempotency_keytype Replay struct{ Status int; Body json.RawMessage }func BeginIdempotent(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, key, requestHash string) (*Replay, error)func FinishIdempotent(ctx context.Context, tx pgx.Tx, member uuid.UUID, key string, status int, body []byte) errorvar ErrIdempotencyKeyReused error
/v1):
| Method | Path | Permission |
|---|---|---|
| PUT | /advances/:id/acquittal |
advances.advance.create (If-Match) |
| POST | /advances/:id/transitions |
advances.advance.transition (Idempotency-Key) |
| GET | /advances/reminders |
advances.advance.read_all |
PUT /advances/:id/acquittal body {"actual_currency"?, "actual_amount", "receipt_date"?, "receipt_document_id"?, "category_id"?, "reason"?, "document_basis"?, "supplier_name"?, "supplier_tin"?, "einvoice_uuid"?, "relaxation_reason"?} → 200, the read shape. POST /advances/:id/transitions body {"kind"?: "advance"|"advance_topup"|"advance_refund", "from", "to", "reason"?, "payout_channel_id"?, "proof_document_id"?} → 200, the read shape. GET /advances/reminders?as_of=YYYY-MM-DD (default today) → {"as_of", "reminders": [{"advance_id", "reference", "claimant_member_id", "due_on", "days_overdue", "level", "recipients", "manager_unresolved"}]}. GET /advances/reminders is registered before GET /advances/:id so gin does not read reminders as an id.
- Step 1: Write the failing tests.
idempotency_test.go — Plan 5’s four, on this table: TestAKeyReplaysItsStoredResponse, TestAKeyWithADifferentRequestIsRefused, TestAFailedRequestGivesItsKeyBack, TestConcurrentSameKeyRequestsSerialize (two goroutines, 10 s contexts, never both nil); plus TestAdvanceKeysAreSeparateFromClaimKeys (the same member and key string used on expenses.BeginIdempotent and advances.BeginIdempotent with different hashes: neither refuses — separate tables, Ruling A23).
acquittal_handlers_test.go:
func TestRecordingAnAcquittalOverHTTP(t *testing.T) { // A Received advance (in-process walk). PUT with If-Match and a verified // receipt -> 200, acquittal.outcome "topup", ETag moved. PUT without // If-Match -> 400; stale -> 409.}func TestAcquittalRefusalsMapToStableStatuses(t *testing.T) { // Accepted case first. actual "-1" -> 422 "field=actual_amount rule=negative:"; // 0 without reason -> 422 "field=reason rule=required:"; a receipt bound to a // claim -> 409 naming "expense_claim" and not "EC-2026-0001" (contract §8.3); // basis einvoice_company without uuid -> 422 "field=einvoice_uuid // rule=einvoice_identifiers:"; another member's // document -> 422 naming receipt_document_id; a draft advance -> 409 // ("the advance is not awaiting acquittal"); Owner recording -> 403.}transition_handlers_test.go (e.UseAdvancePoster(t) right after the fixture — expensestest.New reset the init-registered Poster):
func TestAnOverspendEndToEndOverHTTP(t *testing.T) { // Staff submits (key k1); Owner reviews, approves, pays (payout_channel_id // bank_transfer); Staff acknowledges; Staff PUTs the acquittal (1200); // Owner acquits; Staff submits the top-up (kind "advance_topup"); Owner // approves the excess and pays; Staff acknowledges. Every response is the // read shape in the new state; ControlBalance ends at 0.}func TestTheSameKeyReplaysAndDoesNotMoveTwice(t *testing.T) // replay k1 -> 200, same body, one submit decision; k1 with another body -> 422func TestMissingIdempotencyKeyIs400(t *testing.T)func TestTransitionRefusalsMapToStableStatuses(t *testing.T) { // Table, accepted case first where it applies: stale from -> 409; unknown // to -> 422; missing reason on reject -> 422; Staff approving -> 403 with // the workflow refusal code; stale due date at submission -> 422 naming // due_on; the petty_cash channel -> 422 naming payout_channel_id; each of // Plan 6's refusals mapPayoutErr lists -> 422; acquitting with no // acquittal -> 409 "no acquittal has been recorded"; acquitting a zero // acquittal -> 422 naming "acquittal_branch"; returning a 1200 acquittal // -> 422 "acquittal_branch"; a top-up leg on an advance without one -> // 404; paying a top-up without sign-off (the v2 graph of Task 8's // sabotage test) -> 409; a closed fiscal period at payout -> 409 naming // the period.}func TestACommitTimeLedgerErrorIsMapped(t *testing.T) { // Plan 5 Task 19's deferred-constraint trick, verbatim in shape: a test- // only deferred constraint trigger on journal_line raising 23514 with // CONSTRAINT 'analytic_distribution_required'. PRESENCE: without it, a // payout succeeds (another advance). With it: the payout returns 422 // mapped by ledger.MapError, not 500; the advance is still approved; no // journal_entry committed; no settlement row committed.}reminder_handlers_test.go:
func TestRemindersNeedReadAll(t *testing.T) // Staff -> 403; Owner -> 200func TestRemindersListOverdueAcquittals(t *testing.T) { // A Received advance due 2026-09-10: ?as_of=2026-09-12 -> level // "claimant", recipients [Staff]; ?as_of=2026-09-20 -> "manager", // recipients [Staff, Owner]; ?as_of=bad -> 400. PRESENCE for the // route-order rule: GET /v1/advances/reminders is not a 404 "advance not found".}-
Step 2: Run and watch fail. Step 3: Implement.
idempotency.go:internal/platform/expenses/idempotency.gowithexpense_idempotency_key→advance_idempotency_keyand theadvances:error prefix.acquittal_handlers.go:ifMatch; oneInTx→RecordAcquittal→ the read shape.transition_handlers.go: exactly Plan 5’s handler shape — parse id,decodeStrictbody,Idempotency-Key(400 without; the length limit ofadvance_idempotency_key_length),hash := sha256(method + path + canonical body);err := InTx(func(ctx, tx) { replay := BeginIdempotent(…); if replay != nil { remember; return nil }; a, _, err := platformadvances.Transition(…); body := view(a); FinishIdempotent(200, body) }); thenerr = ledger.MapError(err)beforemapErr(wave-2 §3).reminder_handlers.go: parseas_of(defaultclock.TodayUTC); one readInTx→Overdue.mapErradditions:workflow.ErrStaleState409;workflow.ErrNoSuchEdge,workflow.ErrReasonRequired422;*workflow.RefusedError403 with the refusal code;*workflow.SubmissionRefusedError422 naming each step;ErrNoSuchLeg404;ErrNoAcquittal409;ErrAcquittalBranch422 ("rule=acquittal_branch: the recorded acquittal calls for the other exit");ErrOverspendNotApproved409;ErrNotAcquittable409;*AcquittalError422 (its ownError()is the wire detail);*DocumentBoundElsewhereError409 (itsError()names the kinds only, contract §8.3);*taxrecord.Error422 in Plan 5 R43’sfield=<f> rule=<r>: <sentence>shape (built name for what this plan called*taxrecord.FieldError, Task 1 row 14);ErrNotYourDocument,ErrDocumentNotVerified,ErrCategoryUnusable422 naming the field; Task 7’s channel errors 422 namingpayout_channel_id/proof_document_id;mapPayoutErr— the same five rows asinternal/modules/expenses/transition_handlers.go(copy them:payout.ErrChannelNotFound,ErrChannelInactive,ErrNoProfile,*payout.IneligibleError,*payout.InvalidDetailsError);payout.ErrNoKeyring,ErrUnknownKey,ErrSealBroken500 with the fixed operator sentence;*fiscal.PeriodNotOpenError409;*chart.NoControlAccountError409 (“accounting is not set up: no Employee Advances control account”);*accounting.NoPostingRuleError500;ledger.ErrUnbalanced500;ErrInstanceMismatch,ErrSettlementMismatch,ErrNotPaidOut,ErrNotAcquitted,ErrNothingToAccrue500 (rule invariants — a bug, not the caller’s);ErrPettyCashDeferred422 namingpayout_channel_id(unreachable throughsettle; mapped for safety);ErrIdempotencyKeyReused422; Postgres refusals onadvance_acquittal_while_received/advance_acquittal_is_final→ 409, onadvance_variance_leg_matches/_fixed→ 500 (Go decided the leg; the database disagreeing is a bug).api/openapi.yaml: the three operations;AcquittalInput(with the five tax-record fields anddocument_basisas an enum of the four bases),AdvanceTransition,AdvanceReminders,AdvanceReminder.
-
Step 4: Run.
make generate && go test ./internal/modules/advances/ ./internal/platform/advances/ ./internal/app/ -count=1→ PASS. Sabotage: remove theledger.MapErrorcall →TestACommitTimeLedgerErrorIsMappedred (500); quote; restore. -
Step 5: Commit —
git commit -m "Record acquittals, move advances and list overdue acquittals over HTTP".
Task 16: End to end, the roadmap, and the one full run
Section titled “Task 16: End to end, the roadmap, and the one full run”Waits for everything above.
Files:
-
Create:
internal/modules/advances/e2e_test.go -
Modify:
docs/ROADMAP.md -
Step 1: Write the end-to-end test — the plan in sequence, over HTTP where a route exists. The router composes
modules/advancesandmodules/expenses(forGET /v1/documents/:idand the upload routes) andmodules/accounting(forGET /v1/accounting/reconciliation);e.UseAdvancePoster(t)right after the fixture (a payout keyring"test-1"is installed by it, ascmd/apiinstalls one):
func TestAdvancesFromRequestToAcquittal(t *testing.T) { // 0. Provisioned MY org: GET /v1/org/readiness shows workflow_definitions // done with the three advance kinds published at v1. // 1. OVERSPEND (spec 7.4, exactly). Staff files AD-2026-0001 (MYR 1,000.00, // due 2026-10-15); Staff submits; Owner reviews, approves, pays through // bank_transfer: Dr 1550 1,000 / Cr 1520 1,000, the settlement stored // sealed (GET shows only "****" displays). Staff acknowledges. // Staff (holding documents.document.upload, seeded on filing roles, // contract §8.3) uploads a receipt (presign, MemStore upload, complete) // and PUTs the acquittal: 1,200.00, dated 2026-09-20, meals, basis // einvoice_company with supplier TIN and e-Invoice UUID (Ruling A30). // Owner opens the receipt through GET /v1/documents/{id} (200 — the // advance binder, not a claim, grants it; contract §1.4). A member // holding only advances.advance.read_all opens it too (A33); one with // no advances permission gets 404. Owner acquits: Dr <meals' account> // 1,200 / Cr 1550 1,200; the top-up leg is in draft for 200. // Staff submits the top-up; Owner approves the excess and pays it: // Dr 1550 200 / Cr 1520 200. Running control balance for the advance: // 1,000 -> -200 -> 0. Staff acknowledges; the advance is closed. // 2. UNDERSPEND. AD-2026-0002 (1,000) paid and acknowledged; acquittal // 700.00; acquitted: Dr expense 700 / Cr 1550 700; refund leg for 300; // Staff declares it, Owner confirms: Dr 1520 300 / Cr 1550 300. Control 0. // 3. NOTHING SPENT. AD-2026-0003 (1,000) paid and acknowledged; acquittal // 0 with reason "Trip cancelled"; acquitting it is refused (422 // acquittal_branch) and the read shape's `acquitted` exit says // allowed=false; Owner confirms the full return (received -> returned): // the payout is mirrored (Dr 1520 1,000 / Cr 1550 1,000); no accrual, // no leg. Control 0. // 4. OVERDUE. AD-2026-0004 paid, due 2026-09-25: GET /v1/advances/reminders // ?as_of=2026-10-05 lists only it, level "manager", recipients // [Staff, Owner]; the three closed advances are not listed. // 5. AGING. advances.ListForAging(as of 2026-10-05) lists all four with // IssuedOn 2026-09-21 and their due dates, and HolderName Staff's legal // name (Plan 9 D1's inputs). // 6. GET /v1/accounting/reconciliation passes, including advance_control // and advance_closed_nets_zero; the sum of every advance's control // balance equals 1550's balance (1,000: AD-2026-0004 is outstanding). // 7. Traceability (spec 11A.4): from every journal line on 1550, the entry's // source_id resolves to an advance; that advance's receipt (or reason), // its settlement, its tax record (basis, TIN, e-Invoice UUID — spec // 6.7), and the approving member of every step resolve; // audit.VerifyChain is intact. // 8. Key rotation: install "test-2" with "test-1" previous; // advances.ResealSettlements in the org's transaction; PendingReseal 0; // GET the paid advances: identical settlements view.}-
Step 2: Run it.
go test ./internal/modules/advances/ -run TestAdvancesFromRequestToAcquittal -count=1 -v→ PASS. -
Step 3: Tick the roadmap. Under Plan 7 — Advances add
Plan: [2026-09-22-plan-7-advances.md](superpowers/plans/2026-09-22-plan-7-advances.md) (16 tasks); tick “Acquittal decision table; signed variance fan-out;payout_firstordering” with the indented notes “Three workflow kinds (advance,advance_topup,advance_refund, contract §2.1, §8.1); an acquittal of zero returns the advance throughreturned(Ruling A4)” and “The acquittal carries the §6.7 tax record, validated bytaxrecord(Ruling A30)”. Leave “Acquittal reminders” unticked and add under it: “Computed, not delivered:GET /v1/advances/remindersand theoverdueblock give the escalation level (claimant, then manager, Ruling A28); delivery waits for River (Plan 9), a mailer, and tenant enumeration (contract §4).” Touch no other line. -
Step 4: The one full run.
make lint && go test -p 2 -count=1 ./...→ every package passes. A failure outside this plan’s packages is reported with its package and test name, not fixed by editing another plan’s code. -
Step 5: Commit —
git commit -m "Prove advances end to end: overspend, underspend and full return net the control to zero; tick Plan 7".
Parallel groups
Section titled “Parallel groups”Tasks in one wave touch disjoint files and may run in parallel; waves run in order. “Needs” names the tasks whose output a task consumes. B = waits for Task 1 (the build base confirmed). Errors live beside the functions that raise them, so no two tasks in a wave edit one file.
| Wave | Tasks | Gate | Needs | Why they are disjoint |
|---|---|---|---|---|
| 1 | 1 confirm base · 2 decision table (pure) | — | — | 1: this plan document only. 2: advances/{doc,decide}.go. |
| 2 | 3 seeded graphs | B | 2 | advances/graphs.go. |
| 3 | 4 migrations 00130–00131 + fixture | B | 3 | Migrations, retention_test.go, sqlc.yaml, generated models, advancestest/. Alone among migration tasks. |
| 4 | 5 header, visibility, submission | B | 4 | advances/{advance,visibility,cursor,submission}.go. |
| 5 | 6 acquittal + binder · 7 settlement | B | 6: 2, 5 · 7: 5 | 6: advances/{acquittal,binder}.go. 7: advances/{settlement,nosnapshotopen_test}.go. |
| 6 | 8 transition service | B | 5, 6, 7 | advances/{transition,overspend,available}.go, advancestest/env.go (walk helpers). |
| 7 | 9 reseal · 10 advance posting rules | B | 9: 4, 7, 8 · 10: 8 | 9: advances/reseal.go, cmd/migrate/{reseal.go,reseal_advances_test.go}, DEPLOY_NOTES.md. 10: advances/{posting,posting_advance}.go, advancestest/env.go (UseAdvancePoster — the only fixture edit in the wave). |
| 8 | 11 variance posting rules · 12 aging + reminders | B | 11: 10 · 12: 10 | 11: advances/posting_variance.go, posting.go (append). 12: advances/{aging,reminders}.go. |
| 9 | 13 reconciliation | B | 10, 11 | advances/reconcile.go. |
| 10 | 14 module composition + advance routes | B | 5, 11, 13 | Every shared file (app.go, cmd/*, constants.yaml, openapi.yaml); runs alone. |
| 11 | 15 acquittal, transition, reminder routes | B | 6, 8, 12, 14 | openapi.yaml + module + advances/idempotency.go; alone. |
| 12 | 16 end to end + roadmap + full run | all | 1–15 | Alone. |
Critical path: 2 → 3 → 4 → 5 → 7 → 8 → 10 → 11 → 13 → 14 → 15 → 16.
Notes for later plans
Section titled “Notes for later plans”Binding hand-offs recorded by this plan’s rulings. Each later plan’s author must honour them or amend the ruling with the user.
- Plan 9 (reports), D1 aged advances. Sum over
advances.SourceKinds(e.source_kind = ANY($n)), not= 'advance'— top-ups and refunds post under their own kinds withsource_id= the advance (contract §2.1, Ruling A2).advances.ListForAgingandAgingRefare exactly D1’s names and fields (Ruling A27);IssuedOnis the first payout entry’s date and reversed advances are returned too, so D1’sHAVING sum(...) <> 0is what drops them. The golden fixture D1 asks for isadvancestest.EnvplusReceived/Record(Task 8). An advance with a refused top-up (Ruling A32) is outstanding and ages. - Whoever delivers acquittal reminders (after Plan 9’s River).
advances.Overdue(ctx, tx, asOf)already returns the level and the recipients; a job needs only tenant enumeration (Plan 4 R14’s open decision) and a person-addressed channel. Tick the ROADMAP’s “Acquittal reminders” then. The seven-day manager threshold isManagerEscalationAfterDays(Ruling A28). - Paying an advance from the tin (contract §4), after Plans 7 and 8 have both merged. Plan 7 refuses a
petty_cashchannel twice:settle(ErrChannelPettyCash,internal/platform/advances/settlement.go) and both payout rules (ErrPettyCashDeferred,posting_advance.go,posting_variance.go). The follow-up replaces both, as Plan 8 does for claims: credit the paying holder’s float account, never the channel’sCreditAccountID, under the petty-cash lock order (document → float → instance, contract §5). - Whoever enables
advance_offset(Plan 5 R11, contract §2.1).internal/platform/advancesimportsinternal/platform/expenses(categories, record text — Ruling A17). An offset line inexpensesthat needs an advance must not importadvancesback: move the shared pieces down, or haveadvancesstop importingexpenses, first. - Any plan that adds a table storing sealed payout data inserts its reseal into
cmd/migrate/reseal.gobeside Plan 5’s and Plan 7’s loops (Plan 5 R45, Ruling A18). - Correcting an acquitted figure is deferred (controller ruling 2026-09-22, contract §8.1; Ruling A5). It needs a later adjustment document kind: the engine reverses every unreversed entry of an instance, so “reverse only the accrual” cannot be a dispute uphold on the advance; the adjustment must post its own entries under a kind of its own (and join
advances.SourceKinds, or be summed by the two checks) and must settle the variance leg’s entries too. - A refused overspend needs a write-off / adjustment document kind to close it (controller ruling 2026-09-22, contract §8.1; Ruling A32). Until then the excess stays owed to the claimant on Employee Advances, the advance stays open,
advance_controlexpects it, and it ages. The same adjustment kind can serve both notes.
Contract departures (accepted)
Section titled “Contract departures (accepted)”Found while writing this plan; each was worked around inside Plan 7’s ownership by the ruling named. The controller accepted all six on 2026-09-22 and contract §8.1 now states them — they are no longer open.
- The variance-leg graphs have no
draftstate (§2.1 table:topup_pending → …,refund_pending → …). The validator’s rule 1 refuses them (draft_count) andworkflow.Startcannot start them (errNoDraftSemantic); §1.2 does not relax rule 1 forcash_only. → Ruling A3: each leg starts in its owndraft; the claimant opens it in a later transaction. Pinned byTestTheContractsLiteralGraphsWouldBeRefused(a). - An acquittal of zero cannot enter the accrual state (§2.1:
acquittedaccrues “atactual”; §7.3: actual = 0 is a valid acquittal, “whole advance comes back”).ledger.Postrefuses a zero line and the Poster has no “post nothing” path, so the transition would abort. → Ruling A4: areturnedterminal (cancelledsemantic) whose engine reversal mirrors the payout; no refund leg for zero. Pinned byTestAZeroAccrualCouldNeverPostandTestReturningInFullReversesThePayout. - “Disputes reachable from every post-payout state, with the seeded dismiss/uphold pattern” cannot hold for the two terminal posting states. Dismissing back into
acquittedisaccrual_on_cycle; dismissing back intorefund_confirmedispayout_on_cycle. → Rulings A5, A6: separate dismiss targets (acquittal_confirmed,refund_settled). Pinned byTestTheContractsLiteralGraphsWouldBeRefused(b), (c). - Upholding a dispute after acquittal would misstate the ledger. The engine mirrors every unreversed entry of the advance instance — the payout too — and cannot reach the variance leg’s entries, so the control would not net to zero. → Ruling A5: no uphold edge from
acquittal_disputedin the seeded graph; acquittal correction deferred to a later adjustment kind (Notes for later plans). advance_refundhas no reversing state for an upheld dispute (§2.1 lists onlydisputed), while §2.1 says upholding reverses through §1.1. → Ruling A6:rejectedadded (andrefund_settled).- A refused overspend has no defined end. §2.1 gives
advance_topuparejectedstate but neither it nor the spec says what the ledger should then show: the acquittal already expensed the full actual. → Ruling A32:rejected → draftonly; the excess stays owed and the advance open; a write-off kind is a later plan’s (Notes for later plans). - Resolved by the base build, recorded for completeness: §1.4’s “document lock taken first” contradicted Plan 5’s claim-then-document order, and
Binder.MayReadhad no clock — both fixed by contract §7 and followed here (Rulings A15, A33).
Self-Review Notes
Section titled “Self-Review Notes”Spec coverage.
- §7.1 (receipt deferred to acquittal, never waived) → A8, Task 6 (receipt required for any amount spent, DB CHECK
advance_acquittal_receipt_required), Task 10 (the accrual reads only a decided acquittal). - §7.2 flow → Task 3 (three graphs; A3–A7), Task 8 (the service);
receivedvsacquittedas separate moves → A8; disputes from every post-payout state →TestEveryPostPayoutStateCanBeDisputed(with A5/A6’s documented limits). - §7.3 decision table → Task 2 (pure, table-tested), Task 4 (named CHECKs mirroring each row), Task 6 (applied at recording), Task 8 (re-applied at the decision, branch enforced; A9); overspend sign-off → A10, Task 8.
- §7.4 ledger → Tasks 10, 11 (the 1,000/1,200/200 story exactly), Task 13 (nets to zero), Task 16 step 1.
- §7.5
payout_first→ Task 3. §7.6 reminders → A28, Task 12, Task 15 route, ROADMAP left unticked (contract §4). - §5.5 control account on the accrual (
employee_advances) → Task 3. §5.7 validator → Task 3 viaRequireSeededGraphValidand the base’s drift test. §10.3 rules inside the transition’s transaction → Tasks 10–11 throughworkflow.Transition; reversal is the engine’s (A4, A5 lean on it). §10.6 “Advances control” → A26, Task 13. §11A.3 → purpose and reason CHECKs. §11A.4/§11A.7 → every posting-rule task has a traceability test; Task 16 step 7. §11A.2 → DELETE revoked on all record tables; settlements append-only. §20.2 step 7 (advance seeded and published at v1) → A29, step 60 via the registry; Task 4’s fixture test. - Contract §2.1: three kinds and
source_id→ A2;cash_only→ Task 3; ranges → Global Constraints;advances.*→ A25;/v1/advances/…→ Tasks 14–15; reconcile keys → A26; binder kindadvance→ Task 6; own idempotency table → A23; external channels only → A18; reseal command → Task 9;ListForAging/AgingRef,AD-YYYY-NNNN,due_on→ A27, A13, A12. §4 deferrals → A28, A31, Notes. §5 lock order, start-before-transition, traceability, presence-before-absence → Global Constraints, A21, Task 8’s audit-order test, sabotage steps. §6 migrations commute → Global Constraints. §7 amendments → A15, A33. - §6.7/§11A.6 tax record at acquittal → A30, Tasks 4 (mirrored CHECKs), 6 (shape via
taxrecord), 8 (completeness at the decision), 15, 16. - Not covered, by ruling: acquittal correction (A5), a refused overspend’s closure (A32), advance from the tin and
advance_offset(A18, A31), reminder delivery (A28).
Placeholder scan. No TBD/TODO. Tests are written as named functions with their assertions in comments (the house style of Plans 5 and 6); code blocks give every non-trivial implementation (the decision function, the three graphs, both migrations, the overspend check, every reconciliation and aging query). Where a step copies merged code (expenses’ settle, mayTake, idempotency.go, reseal.go, the module HTTP helpers, mapPayoutErr), it names the file.
Type consistency. Kind/KindTopup/KindRefund/SourceKinds/ControlKind/BinderKind/ReadAllPermission (Task 2) are the names used in Tasks 3–16. AcquittalFacts/AcquittalDecision/Outcome*/AcquittalError (2) are what Acquittal.Facts (6), Transition (8), Available (8) and the read shape (14) use. Advance fields (5) — InstanceID, TopupInstanceID, RefundInstanceID, BaseAmount, HolderPartyID, PayoutChannelID, Semantic — are what 6–13 read. settleInput/Settlement/SettlementInThisTx(instanceID) (7) are keyed by instance, which is what both payout rules (10, 11) pass. payoutAccounts/journalFor (10) are reused by 11. Rules() holds two rules after 10 and four after 11; Task 14’s boot test asserts four. ManagerEscalationAfterDays, EscalationFor, Overdue (12) are what 14 (overdue block) and 15 (route) call. DocumentBoundElsewhereError (6) is what 15 maps. workflow.Instance.PayoutOpen, posting.Event.PastPayout, EdgeSpec.ReversesAccrual, workflow.CashOnly, RegisterSeededGraph, RequireSeededGraphValid(t workflowtest.TB, kind), documents.MayRead(ctx, tx, c, …) are the base’s names as built (Reconciliation record).
Decisions taken by the controller (2026-09-22). Every question this plan raised is closed:
- The six contract departures (A3–A6, A32, and A4’s
returned) — accepted; contract §8.1. - Tax record at acquittal — captured, validated by
taxrecord(A30). - A refused overspend — stays owed and open; a write-off kind is a later plan’s (A32, Notes).
- Correcting an acquitted figure — deferred to a later adjustment kind (A5, Notes).
- Reminder threshold — seven days, as the named constant
ManagerEscalationAfterDays(A28). - Document routes — fixed by the base (contract §8.3); the advance binder honours
advances.advance.read_all(A33), and the bound-elsewhere 409 names only the kind (A15).
Reconciliation record
Section titled “Reconciliation record”Read on phase1-wave3 at 1d9ddab while this plan was written. Task 1 confirms it on the build base and appends “Confirmed on <sha>” or the changes.
| # | Symbol | As built | Evidence |
|---|---|---|---|
| 1 | posting.Event.PastPayout |
identical to contract §1.1; PostsToLedger = PastAccrual || PastPayout for Rejected/Cancelled |
internal/platform/posting/posting.go:53-75 |
| 2 | workflow_instance.payout_open |
BOOLEAN NOT NULL DEFAULT false; set entering payout, cleared (with accrual_open) entering rejected/cancelled |
db/migrations/00034_workflow_payout_open.sql; internal/platform/workflow/instance.go:348-368 |
| 3 | workflow.Instance.PayoutOpen |
bool, scanned by instanceSelect |
internal/platform/workflow/instance.go:61 |
| 4 | reversal flag | name unchanged, EdgeSpec.ReversesAccrual; rule 5 now starts from accrual and payout states |
internal/platform/workflow/validate.go:440-461 |
| 5 | workflow.CashOnly |
"cash_only"; rule 3(c) adds accrual_in_cash_only and returns before the payout-ordering checks; (a), (b) unchanged; named CHECK workflow_definition_posting_order_known |
model.go:51-55; validate.go:347-356; 00034 |
| 6 | rule 1 under cash_only |
unchanged: exactly one draft state; Start still requires one |
validate.go rule1; instance.go:171-179 |
| 7 | workflow.RegisterSeededGraph |
(kind string, build func(ownerSeat uuid.UUID) Graph); panics on bad/duplicate kind, expense_claim, nil build, or a built graph of another kind (it calls build(uuid.Nil)); SeededGraphs = expense_claim then sorted kinds |
internal/platform/workflow/seed.go:119-170 |
| 8 | workflowtest.RequireSeededGraphValid |
(t TB, kind string), TB = require.TestingT + Helper() |
internal/platform/workflow/workflowtest/fixture.go:147-169 |
| 9 | binder API | as contract §1.4 except MayRead(ctx, tx, c clock.Clock, documentID, memberID) (contract §7); plus UnregisterBinderForTest |
internal/platform/documents/binder.go |
| 10 | expenses binding | ErrDocumentBoundElsewhere, *DocumentBoundElsewhereError{Bindings} with Is; AttachDocument locks claim → document → BoundElsewhere; the expenses binder counts settlement proofs as bound and leaves read_all to its route |
internal/platform/expenses/attachment.go, binder.go |
| 11 | document route | uploader → expenses.claim.read_all → documents.MayRead(ctx, tx, h.deps.Clock, …) |
internal/modules/expenses/document_handlers.go:242-259 |
| 12 | workflow.Refusal |
has Code string (and Gaps) — unchanged from main |
internal/platform/workflow/authorize.go |
| 13 | reversal source | ReverseAll copies SourceKind/SourceID from the original — unchanged from main |
internal/platform/accounting/ledger/reverse.go:184 |
| — | account.owner_module |
exists (00054), frozen by account_immutable; not used by Plan 7 (1550 has none) |
db/migrations/00054_account_owner_module.sql |
| 14 | internal/platform/taxrecord |
landed on phase1-wave3 at 4f3c48d. Differs from the names this plan assumed: CheckEmployeeNamed → CheckBasisAgainstCategory(basis *string, employeeNamedDocumentAllowed bool) error; *FieldError → *Error{Field, Rule, Detail}; no Complete function (its doc comment: completeness is each caller’s business at submission time — Task 8 implements its own unexported checkTaxRecordComplete, returning *AcquittalError, not a taxrecord type); Record{SupplierTIN, EInvoiceUUID, DocumentBasis, RelaxationReason *string} has no SupplierName field — expenses keeps that on its own line type too (internal/platform/expenses/linerules.go:65), so advances.Acquittal/AcquittalInput carry SupplierName themselves, checked with expenses.CheckRecordText, same pattern. Tasks 6, 8, 15 rewritten below to the built names. |
internal/platform/taxrecord/taxrecord.go |
| 15 | document routes | landed on phase1-wave3 at d955261: documents.document.upload permission gates POST /documents; GET /documents/:id and POST /documents/:id/complete are authenticated-only, decided by uploader / documents.MayRead / expenses.claim.read_all; bound-elsewhere 409 names only the kind — exactly as contract §8.3 and row 15’s original text anticipated, no rewrite needed |
internal/modules/expenses/document_handlers.go; docs/superpowers/specs/2026-09-22-phase1-wave3-contracts.md §8.3 |
Task 1 confirmation (this build). First pass: this branch stood on phase1-wave3 at 7158eb0 (the controller held it there rather than the branch tip 6e1bc0c, a red WIP checkpoint marked “do not merge this commit into a plan branch until they pass”). Gate tests (workflow, documents, posting) passed; rows 1–13 held unchanged; rows 14–15 were not yet landed, so Tasks 2 and 3 proceeded (neither touches taxrecord or document routes) and Tasks 4, 6, 8, 15, 16 waited.
Second pass (this update): phase1-wave3 gained d955261 (document routes, row 15), b306f4d (locale, unrelated), 4f3c48d (taxrecord, row 14) — the WIP 6e1bc0c is now green underneath them. This branch was rebased cleanly onto phase1-wave3 at 4f3c48d; the gate tests plus internal/platform/advances and internal/platform/taxrecord all pass. Row 15 landed exactly as anticipated (no rewrite needed). Row 14 landed with three name/shape differences from what this plan assumed, reconciled above and rewritten into Tasks 6, 8 and 15’s Interfaces, code and test comments in this commit. Tasks 4–16 may now proceed.