Plan 8 — Petty Cash 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 finance administrator opens a petty-cash float for a member. The float is its own postable cash account under the chart’s 1530 Petty cash floats header, owned by the pettycash module. Its stored cash_balance and committed are locked FOR UPDATE for every overdraw check, and an append-only float_movement ledger explains every change. Cash enters the tin only through an approved float_topup (a replenishment is a top-up sized to the VERIFIED spends it restores), or through a reversal crediting cash back. Cash leaves through a two-phase petty_cash_spend (recorded accrues and commits; spend_paid pays only when the reviewer’s outcome is VERIFIED), through an expense claim settled from the tin (Plan 5’s petty_cash channel, credited to the paying holder’s float account), or through a close-out that banks the remainder against a refund proof. Every posting goes through Plan 4’s engine in the transition’s own transaction. Three reconciliation checks prove that the stored balances, the movements and the general ledger agree.
Architecture: One platform package and one module.
internal/platform/pettycashowns everything with a petty-cash rule in it:- floats and their accounts; the movement ledger; spends, top-ups, replenishments and close-outs;
- the three seeded workflow graphs (registered through
workflow.RegisterSeededGraph), the four posting rules and the three reconciliation checks; - the two document binders;
- a small settlement API that
internal/platform/expensescalls.expensesimportspettycash;pettycashnever importsexpenses.
internal/modules/pettycashis the HTTP shell for/v1/pettycash/…and registers the posting rules at boot.- The database enforces what must never drift:
cash_balancemoves only through afloat_movementinsert (a trigger), and a direct update is refused;- available cash (
cash_balance − committed) andcommittedcan never go negative; - each holder has one live float, and a closed float is empty;
- a spend’s money fields change only in draft, and its reviewer outcome only while it is recorded.
- Plan 8 edits Plan 5’s claim code in exactly the places the wave-3 contract §2.2 lists (and one mapping case that the contract missed; see Ruling PC2).
Tech Stack: Go 1.26 · Gin · pgx v5 (raw SQL, the house pattern) · goose · sqlc (models only) · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money. No new module dependency.
Specs (read before any task):
docs/superpowers/specs/2026-09-22-phase1-wave3-contracts.md— BINDING.- §1 is the base-branch seam this plan consumes (reversal past payout,
cash_only, the seeded-graph registry, the document binder registry, module-owned accounts). - §2.2 is Plan 8’s shape. §3 is ownership. §5 is the lock order and the binding lessons.
- Built on
phase1-wave3(ec45790), amended by its §6 (migrations apply out of order) and §7 (the base-build amendments). - Every base symbol this plan calls was read from that commit. See Reconciliation record at the end. Task 1 only confirms the branch sits on it.
- §1 is the base-branch seam this plan consumes (reversal past payout,
docs/superpowers/specs/2026-09-21-phase1-wave2-contracts.md§3 and…wave1-contracts.md§3 and §5 — the binding lessons and the append-only shared files.docs/superpowers/specs/2026-09-20-erp-architecture-design.md— §8 in full, and §5.5, §5.7, §9.5, §10.1, §10.3, §10.6, §11A, §20 (§20.1.9 constants, §20.2 step 7, §20.3).docs/superpowers/plans/2026-09-21-plan-5-expense-claims.md— Ruling R24 and Notes for later plans (the petty-cash hand-off). Its Tasks 13–16 and 19 are the code this plan edits. Its Rulings R21, R22, R28, R29 and R43 are the patterns copied here.docs/superpowers/plans/2026-09-21-plan-6-payout-channels.md— Ruling P6 (user decision): a petty-cash settlement credits the paying holder’s float account, neverChannel.CreditAccountID.- Plan 9 (
git show plan-9-reports:docs/superpowers/plans/2026-09-21-plan-9-reports.md), section Deferred until Plans 7/8, D2 — the names this plan exports (ListFloats,GetFloat,FloatRef).
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 goes only under
internal/platform/pettycash/…andinternal/modules/pettycash/…, plus the §2.2 edits ininternal/platform/expensesand the one named exception in Ruling PC2. - Never edit
internal/platform/posting,internal/platform/workflow,internal/platform/accounting,internal/platform/documents,internal/platform/payout, any other base-branch file, or Plan 7’s packages. pettycashnever importsinternal/platform/expenses. The compiler enforces this from Task 14 on, becauseexpensesimportspettycashand a cycle would not build.
- Go code goes only under
-
Ranges.
- Migrations
00150–00169; this plan uses00150–00152. - Provisioning orders
120–129; this plan uses none (Ruling PC27). - Permissions
pettycash.*. OpenAPI paths/pettycash/…(served under/v1). - Reconcile keys
float_integrity,float_commitment,float_ledger. - Binder kinds
petty_cash_spend,float. - Workflow kinds
petty_cash_spend,float_topup, andfloat_closeout. The third is added by Ruling PC3.
- Migrations
-
Shared files are append-only (wave-1 §3, extended):
- the
constants.yamlpermissions list,api/openapi.yaml,internal/app/app.go,internal/app/routes_test.go(composedModules); - the module imports in
cmd/{api,migrate,orgadmin,worker}/main.goandcmd/{migrate,orgadmin,worker}/wiring_test.go; - the
internal/kernel/db/retention_test.golists; docs/ROADMAP.md: tick only Plan 8’s lines.
Generated files are regenerated with
make generate, never hand-merged. - the
-
Tenant isolation, both layers.
- Every new table has
org_id UUID NOT NULL,ENABLEandFORCE ROW LEVEL SECURITY, and a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid. - Every SQL statement names
org_id = $nfor each org-scoped table it touches (join equalities count), and everyINSERTnamesorg_id. - Every FK between two org-scoped tables is composite:
(org_id, x) REFERENCES t (org_id, id).
- Every new table has
-
Named constraints (wave-2 §3). Every
CHECK,UNIQUEandEXCLUDEis writtenCONSTRAINT <name> …, and no name ends in_check,_keyor_excl. Error mapping is keyed on these names (and on unique-index names). -
Pinned functions. Every function and trigger function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table. -
No
SET CONSTRAINTSin non-test Go. -
Every table is in exactly one list in
retention_test.go.amendable: floats, spends, top-ups.appendOnly: movements, claim-settlement links, close-outs, and replenishment lines (which hold a column-levelUPDATE (released_at)only, as Plan 5’s settlement table does).operational: the idempotency store.
-
Lock order (wave-3 §5, Ruling PC13). The order is:
idempotency key row → the business document’s row (
petty_cash_spend,float_topup,expense_claim)FOR UPDATE→ thepetty_cash_floatrowFOR UPDATE→ theworkflow_instancerow (insideworkflow.Transition) → the movement insert (whose trigger updates the already-locked float) →audit.Write.A binding path locks the consumer’s own record first, then the document (
documents.Lock), then callsdocuments.BoundElsewhere. This is contract §7, which replaces §1.4’s “taken first”. Where a document is bound in a transaction that also takes the float, the order is record → document → float → instance. Nothing writes an audit row beforeworkflow.Transitionin a transaction that transitions. This plan adds no advisory-lock class. -
Migrations commute (contract §6).
cmd/migrateappliesupwith goose’s allow-missing option, so a migration numbered below one already applied can still run. Plan 8’s migrations only create objects, prefixedpetty_cash_,float_orpettycash_. They reference base tables (organization,organization_member,account,document,journal_entry,workflow_instance,workflow_state,tax_code), and they redefine no object that another range owns. Keep it that way. -
Every PL/pgSQL function body in a migration is wrapped in
-- +goose StatementBegin/-- +goose StatementEnd, as00072does. -
Commit-time errors. 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 open transactions. -
Start an instance in an earlier transaction than its first
Transition(workflow/instance.go:283-298).workflow.Startandworkflow.SetAmountaudit. So creating or re-pricing a spend, top-up or close-out happens in its own request, never in the one that moves it. -
Money is
money.Amount(NUMERIC(19,4)), compared withCmp, never==. On the wire it is a string. Floats hold the organization’s base currency only (Ruling PC11). -
The business date is
clock.TodayUTC(c). Nevertime.Now()in code under test. -
Record-bearing free text (spend description, supplier name, relaxation reason, top-up reason; void/dispute reasons go to the decision log) is English or Bahasa Malaysia (§11A.3). Refuse Han, Kana and Hangul in Go (
checkRecordText) and in a namedCHECKusing!~ '[-ヿ㐀-䶿一-鿿가-]'(written<cjk>below; write it out in full at each use). -
Errors are RFC 7807. Handlers
c.Error(apperr.New(kind, detail))and return. Platform code returns sentinel or typed errors, never*apperr.Problem. Field refusals travel asfield=<name> rule=<code>: <sentence>(Plan 5 R43). -
Every route goes through
httpx.Protectedwith apettycash.*permission registered in the module’sinit(), and is documented inapi/openapi.yamlin the same commit. -
postingandaccounting.DefaultRules()are process-global. A test that needs a Poster callse.UseRules(t)(Task 4), which registers Plan 4’s realaccounting.NewPosterover a fresh RuleSet holdingexpenses.Rules()andpettycash.Rules(). Never uset.Parallel(). -
All tests of
internal/platform/pettycashare external (package pettycash_test).pettycashtestimportsexpensestest, which importsexpenses, which importspettycash. An internal test file importingpettycashtestwould be a cycle. Internals reach tests throughexport_test.goonly. -
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/pettycash/... -count=1). Runmake generateafter any migration, OpenAPI orconstants.yamlchange. Run one fullgo test -p 2 -count=1 ./...in Task 19 only. -
Merges and pushes are the user’s. Commit on
plan-8-pettycash; never push, never merge. -
Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”These are decisions taken where the specs and the contract are silent or ambiguous, or where the contract cannot be followed as written. Format: Ruling: <decision> — <why> — <cost if wrong>. Rulings marked (contract problem) or (interpretation) are listed for the user in Self-Review Notes → Needs the user’s decision.
-
PC1 Ruling: Packages and tests are laid out as follows.
internal/platform/pettycash(+/pettycashtest, a non-test package likeexpensestest) and one module,internal/modules/pettycash.pettycashtest.Envembeds*expensestest.Env, a test-only import ofexpenses, and adds a Cashier (Ruling PC18).- Every
pettycashtest is external (see Global Constraints).
Why: Plan 9 D2 names the package. Plan 5’s test world already provisions everything a float needs (chart, journals, FY, step 60, members, documents, payout channels and keyring), and duplicating it would drift. Cost if wrong: if
expensestestchanges shape,pettycashtestchanges with it. -
PC2 Ruling (contract problem — ACCEPTED by the controller; contract §2.2 amended to match): Besides the contract’s §2.2 list, Plan 8 edits one
caseininternal/modules/expenses/errors.go(theErrChannelPettyCash/ErrPettyCashDeferredcase) and adds one test tointernal/modules/expenses/transition_handlers_test.go.- Why: once petty cash settles, a claim transition can be refused by the float, because the payer holds no open float, or the float is closing, or it lacks the available cash. That refusal reaches the client through Plan 5’s transition handler.
- The only existing mapping for it answers “petty-cash reimbursement arrives with petty-cash floats (Plan 8)”. Without the edit, every float refusal on a claim is either that sentence or a 500.
- The contract lets Plan 8 remove the two sentinels (§2.2, “those two errors”), and removing them breaks that
caseanyway. - Cost if wrong: if the user refuses, Plan 8 must keep
ErrChannelPettyCashand wrap every float refusal in it. The client then gets Plan 5’s stale sentence until Plan 5’s owner edits the case.
-
PC3 Ruling (contract problem — ACCEPTED by the controller; contract §2.2 amended to match): The close-out is a third workflow kind,
float_closeout(cash_only), with its own table and its own grants.- Graph:
requested(draft) →confirmed(payout, terminal);requested→cancelled(cancelled). - Its payout rule posts
Dr bank / Cr the float's accountfor the frozen amount. - Why: spec §8.1 and D2 require the
closing → closedposting, “cash back to bank, float account to zero”, and require it to go through the engine. The contract gives Plan 8 two kinds and no path for this posting.ledger.Postrefuses amanualline on an owned account (contract §1.5).- An
accrualorpayoutentry needs a workflow instance (journal_entry_workflow_source,00052). journal_entry.semanticis closed.
- A kind is a free identifier (Plan 3 Ruling 22), and no Plan 7 kind collides with it. Its own table lets a withdrawn close-out be retried, because one instance is allowed per
(kind, source_id). - Cost if wrong: if the user wants the contract amended differently (a base-branch semantic, or a manual-post exemption for owners), Task 13 is replaced and the rest stands.
- Graph:
-
PC4 Ruling (contract problem — ACCEPTED by the controller; contract §2.2 amended to match): Each of
petty_cash_spendandfloat_topupgains one state,paid_confirmed(semanticacknowledged, terminal), as the target of a dismissed dispute.- Why: the contract makes
spend_paidandpaidterminal payout states and listsdisputed. Two engine rules then leave a dispute with nowhere to be dismissed to:- Plan 3 Ruling 3: a terminal state may be left only into a dispute (
terminal_exit_not_dispute). - Validator rule 3(a): no payout on a cycle.
spend_paid → disputed → spend_paidis refused, and the Poster’s once-only rule R8 would refuse the re-entry anyway.
- Plan 3 Ruling 3: a terminal state may be left only into a dispute (
- Without the new state, raising a dispute would be the same as voiding, and the resolver could never refuse it. The expense-claim graph solves the same problem with
received. paid_confirmedis reached only by dismissal, posts nothing, and may itself be disputed again.- Task 4’s
TestADismissedDisputeCannotReturnToAPayoutStateproves the refusal the new state avoids. - Cost if wrong: if the user prefers “a spend dispute can only be upheld”, drop the state and its two edges from both graphs.
- Why: the contract makes
-
PC5 Ruling (interpretation — ACCEPTED by the controller): The graphs are the contract’s §2.2, with semantics and gates as follows (Task 4 pins every row).
-
petty_cash_spend(accrual_first):Edge Gate Notes draft→recordedreporter record; accrual, controlpetty_cash_aprecorded→spend_paidreporter pay; payout, terminal. The holder hands the cash outrecorded→voidedreporter void; reason required, reversesrecorded→not_reimbursabletarget reject; reason required, reversesspend_paid→disputedreporter reason required paid_confirmed→disputedreporter reason required disputed→voidedsource uphold; reason required, reversesdisputed→paid_confirmedsource dismiss- Voiding from
spend_paidgoes throughdisputed, because a terminal state may only be left into a dispute (Plan 3 Ruling 3). - Upholding reverses both legs through the engine’s mirror. §1.1’s
PastPayout/PastAccrualmake it fire.
- Voiding from
-
float_topup(cash_only):Edge Gate Notes requested(draft) →reviewed(pending)target reviewreviewed→approved(pending)target approveapproved→paidtarget pay; payout, terminalrequested→cancelledreporter withdraw; reason requiredreviewed→rejectedadvance reason required approved→rejectedadvance reason required paid→disputedreporter reason required paid_confirmed→disputedreporter reason required disputed→paid_confirmedsource dismissdisputed→rejectedsource uphold; reason required, reversesrequestedis the draft, because rule 1 needs exactly one. So a top-up has no separate “submit” step: the first move is the reviewer’s. -
float_closeout: see PC3.requested→confirmedis target-gated.
Why: these are the contract’s keys and semantics, completed only where the engine requires it. Cost if wrong: renaming a key touches Task 4 and the tests that name it.
-
-
PC6 Ruling: Seeded grants, per kind (separate lists, spec §8.3):
Kind State Grantee petty_cash_spendnot_reimbursablethe holder’s superiorpetty_cash_spenddisputedthe root seat float_topupreviewedsuperiorfloat_topupapprovedunit_headfloat_topuppaidthe root seat float_topupdisputedthe root seat float_closeoutconfirmedthe root seat Floors are nil. Why: these name only what provisioning guarantees, exactly as the seeded expense claim does (Plan 3 Ruling 16). An org that wants the claim approvers to approve top-ups too says so by listing the same people (§8.3). Cost if wrong: a float held by the root seat’s holder cannot be topped up until an administrator publishes grants that name someone else. The engine refuses self-approval (
authorize.go:252), which is the right refusal. -
PC7 Ruling (controller ruling, reversing this plan’s first draft): Reviewer outcomes are strings:
queried,verified,not_reimbursable(AGENTS.md, §20.1.9: “values are strings, never integers”).- Storage is
petty_cash_spend.reviewer_outcome TEXT NULLwithCONSTRAINT petty_cash_spend_outcome_known CHECK (reviewer_outcome IN ('queried','verified','not_reimbursable')). NULL means not yet reviewed, which is distinct fromqueried. - In Go:
type Outcome stringwithOutcomeQueried,OutcomeVerified,OutcomeNotReimbursable, andOutcomes(the closed set). On the wire: the OpenAPI string enumReviewerOutcome(nullable). - The set lives where Plan 6 put settlement behaviours: a DB CHECK, the Go set, and the OpenAPI enum, pinned to each other by
TestReviewerOutcomesMatchTheCheck(Task 9) andTestReviewerOutcomeEnumMatchesThePlatform(Task 17). It crosses the wire, so §20.1.9 puts it inapi/openapi.yaml, notconstants.yaml. - Every gate tests
== OutcomeVerifiedexactly. That meets spec §8.2’s reason for zero (a truthy check must fail towards “not verified”): no value but"verified"opens the pay edge.TestOnlyAVerifiedSpendIsPaid(Task 11) proves the pay edge refuses NULL,queriedandnot_reimbursable.
Cost if wrong: none; the contract’s §2.2 is amended by the controller to match.
- Storage is
-
PC8 Ruling: Who reviews a spend.
- The reviewer is any member whom
workflow.AvailableTransitionsshows asAllowedon an edge from the spend’s current state into arejected-semantic state. In the seed that isrecorded → not_reimbursable, granted to the holder’s superior. - The reviewer is never the holder (
ErrSelfReview). - The permission
pettycash.spend.reviewonly opens the route. - The outcome may be revised while the spend is
recorded. A DB trigger freezes it in every other state, and every revision is audited.
Why: authority to decide comes from workflow grants (§5.4), and reviewing is the decision the
not_reimbursableedge already encodes. The contract fixes the graph, so no state is added for the review. Matching by semantic, not by state key, survives an organization renaming its states. Delegation and escalation apply for free. Cost if wrong: if the org’s definition gives the rejection edge to someone who should not verify, the fix is the grant, not code. - The reviewer is any member whom
-
PC9 Ruling: Route gates, decided by semantics, not by state keys (contract §2.2: “the route is the gate”).
- An edge from an
accrualstate into apayoutstate (pay) requiresreviewer_outcome = 'verified'exactly (ErrNotVerified). - An edge from an
accrualstate into arejectedstate (not reimbursable) requires'not_reimbursable'(ErrOutcomeMismatch). - An edge into
cancelled(void) is open whatever the outcome.queriedmeans “void and re-record”. AvailableTransitionsstill lists every edge unchanged, and the read shape addsoutcomeso a client can grey the pay button.
Cost if wrong: none; the gate is one comparison.
- An edge from an
-
PC10 Ruling: At most one live (not
closed) float per holder (partial unique indexpetty_cash_float_one_live_per_holder). The holder is fixed for the float’s life; changing holder means closing and opening a new float.- Why: §8.4 gates a claim settlement on “the payer holding an open float” and debits “the paying holder’s float”. Two live floats would make that ambiguous.
- Cost if wrong: a holder running two tins (two sites) cannot, until a settlement names its float.
-
PC11 Ruling: Each float’s account is created by
chart.CreateAccountwhen the float opens:- parent = the active header whose code is
1530(contract §2.2); refuse withErrNoFloatHeader(409) if it is missing, inactive, postable, or not an asset; Typeasset,Currency= the base currency,OwnerModule: "pettycash"(contract §1.5);- code
1530.<float code>(for example1530.PF-2026-0001, withinaccount_code_format); namePetty cash float <float code>. - The holder’s name is not put in the chart.
Floats hold base currency only. Why: a float is cash in one currency, and claims already reimburse in base (Plan 5 R5). Cost if wrong: a foreign-currency tin needs a currency column and FX on movements later.
- parent = the active header whose code is
-
PC12 Ruling: Balances are stored and moved only in a movement’s transaction.
cash_balanceis changed only by thefloat_movementAFTER INSERTtriggerfloat_movement_apply. TheBEFORE UPDATE OF cash_balancetriggerpetty_cash_float_balance_guardrefuses any direct change withpetty_cash_float_balance_by_movement. Socash_balance = SUM(movements)is a database fact, andfloat_integrityis defence in depth.committedhas no movement (it is not cash). Go changes it under the float lock, in the transaction of the spend transition that commits or releases it.- Constraint
petty_cash_float_committed_nonnegative(committed ≥ 0). - Constraint
petty_cash_float_available_nonnegative(cash_balance − committed ≥ 0, which with the one above impliescash_balance ≥ 0). - Go checks both under the lock first, so a caller gets a typed
*InsufficientFloatErrorrather than a 23514.
Why: this is spec §8.1, and contract §2.2 (“cash_balance and committed change only in the same transaction as their movement”). Cost if wrong: none found; the trigger is the one writer.
-
PC13 Ruling: Lock order is the order in the Global Constraints, amended by contract §7 for binding (record → document → float → instance).
- Binding a spend’s receipt: spend → document.
- Requesting a close-out, whose binder record is the float: float → document, then
workflow.Start. - Close-out rows are append-only (no
UPDATEgrant, so noFOR UPDATE), so a close-out transition locks the float alone, as the contract says. Every lock-order test proves the float lock sits after the document lock and before the instance lock, by probing the other two rows withFOR UPDATE NOWAITwhile the float is held. Cost if wrong: none; wave-3 §5 fixes it.
-
PC14 Ruling: Movements follow the posting.
- A movement is written after
workflow.Transitionreturns and before this plan’s ownaudit.Write. Itsjournal_entry_idis found throughledger:- a forward leg uses the instance’s unreversed entry of the semantic just posted;
- a reversal uses that entry’s
ReversedByIDfromledger.Get.
- Constraint
float_movement_entry_once: one movement per journal entry. - Constraint
float_movement_reversed_once: a reversal names the movement it reverses, at most once. - A float is credited back only when the payout entry it debited now has a reversal (contract §2.2). Voiding a
recordedspend releasescommittedand writes no movement.
Cost if wrong: none; the ledger is the source of truth.
- A movement is written after
-
PC15 Ruling: The four posting rules (spec §10.3, §8.2–8.4). The party on every line is the holder’s party.
Rule Dr Cr Journal (petty_cash_spend, accrual)spend.expense_account_id(withtax_code_idand memo<ref> <description>)the resolved petty_cash_apcontroldefault purchase(petty_cash_spend, payout)the control the accrual credited, at the frozen balance (read off the accrual, as Plan 5 R26 does) the float’s account default cash(float_topup, payout)the float’s account the default bankjournal’s default accountthat journal (float_closeout, payout)the default bankjournal’s default accountthe float’s account the default bankjournal- Every rule refuses an instance mismatch and an amount drift.
- Every rule test asserts a path to its source document and its decision (§11A.7, wave-3 §5): the spend’s receipt, the top-up’s approval, the close-out’s refund proof.
Cost if wrong: an org that funds floats from a second bank account posts to the default one until a later plan adds a chooser.
-
PC16 Ruling (controller ruling): A spend carries the tax record, and names a GL expense account, not an expense category.
-
A petty-cash receipt is expense evidence, exactly as a claim receipt is (§6.7, §11A.6). So a spend carries these columns, validated by the database-free package
internal/platform/taxrecord:supplier_name,supplier_tin,einvoice_uuid,einvoice_validated_at;document_basis(einvoice_company,foreign_supplierorrelaxation_receipt);relaxation_reason.
The base branch is extracting
taxrecordfromexpensesnow, and Task 1 reconciles its exact names. Migration00151mirrors00072’s named CHECKs on the same columns, citing each. -
einvoice_employeeis refused on a spend (*FieldError{document_basis, employee_named_not_allowed}). Claims allow it only where the expense category permits it (Plan 5 R8), a spend has no category to consult, and a purchase paid from the company’s tin is the company’s. -
document_basisis optional in draft and required to enterrecorded, with completeness checked bytaxrecordthen, as claim submission does. -
expense_account_idmust be an active, postableexpenseaccount (viachart.GetAccount). A spend has an optionaltax_code_id, a description, an expense date and one receipt. -
The receipt must be verified, uploaded by the holder, and not bound elsewhere (
documents.BoundElsewhere). It is required to enterrecorded, and replaced only in draft (DB trigger).
Why a GL account and not a category: categories live in
expense_category, reachable only throughexpenses, whichpettycashmay not import (contract §2.2). Recorded in Notes for later plans: share categories once they move out ofexpenses. Cost if wrong: an org whose claim categories allow employee-named e-Invoices cannot use one for a petty-cash purchase until categories are shared. -
-
PC17 Ruling: Commitment by transition.
- Entering
accrual(record) commits:available ≥ amountunder the lock, thencommitted += amount. - Entering
payout(pay) pays: movementspend_payoutof−amount, thencommitted −= amount. - Entering
cancelled/rejectedfrom anaccrualstate releases:committed −= amount, no movement. - Entering
cancelledfrom a dispute over a paid spend: movementspend_payout_reversalof+amount, reversing the payout movement. - Recording and paying need the float
open. A reversal needs it notclosed(PC33).
Cost if wrong: none; this is §8.1–8.2 verbatim.
- Entering
-
PC18 Ruling (amended by controller ruling 4): A claim settled from the tin.
-
The paying holder is the member who moves the claim into its payout state (
in.Actor, which isEvent.ActorMemberIDat posting). -
settle()’s petty-cash branch does two things:- It calls
pettycash.ReserveSettlement. This locks the payer’s live floatFOR UPDATE(claim → float: the claim row is already locked) and requires itopen, in the claim’s currency, withavailable ≥ total. - It makes the settlement row record the account actually credited:
expense_claim_settlement.credit_account_id= the float’s account, never the channel’s 1510 placeholder. After the row is inserted,pettycash.LinkSettlementwrites Plan 8’s own link rowfloat_claim_settlement (settlement_id → float_id), so00073is untouched.
The claim’s audit row names the settlement, and the settlement names the float’s account and float, so no record says cash-in-hand was credited.
- It calls
-
PayoutRule.Build’s petty-cash branch creditss.CreditAccountID, which is now the float’s account, on the defaultcashjournal. First it checks that account equalspettycash.SettlementAccount(payer); a mismatch givespettycash.ErrSettlementPayerMismatch. So a settlement row naming 1510 for petty cash cannot post. The debit side andSettlementInThisTxare unchanged. -
After
workflow.Transition,pettycash.RecordSettlement(settlementID)writes theclaim_settlementmovement against the linked float, using the instance’s payout entry. An external settlement has no link and writes nothing. -
On a transition into
rejected/cancelled,pettycash.LockSettlementFloatruns beforeworkflow.Transition(claim → float → instance). After the transition,RecordSettlementReversalcredits the float back once the payout entry is reversed. -
Tests use a Cashier: a member under the root seat who holds a float and pays claims by an
expense_claimdelegation from the Owner. This is the realistic shape, because the seededcompletedgrant is the root seat’s.
Why: this is Plan 5’s notes, Plan 6 P6, contract §2.2, and the controller’s ruling that the settlement row records the account actually credited. Cost if wrong: a payer who is not the member recorded as moving the claim debits the clicker’s float. That is correct by construction.
-
-
PC19 Ruling: Top-ups.
- Requested by the float’s holder only, with a reason and an amount.
- Paying one (entering
payout) needs the floatopenand writes movementtopupof+amount. - An upheld dispute (entering
rejectedpast payout) reverses the leg. It needsavailable ≥ amount, because the cash must still be in the tin (*InsufficientFloatErrorotherwise), and writestopup_reversalof−amount. - Every entry into
rejected/cancelledreleases the top-up’s replenishment lines.
Cost if wrong: none; this is §8.3.
-
PC20 Ruling (controller ruling: reversed, so claims settled from the tin are included): Replenishment, concretely: the imprest restores every approved disbursement.
-
A replenishment is a
float_topupwithpurpose = 'replenishment'. Its frozen lines each name the float movement they restore (float_replenishment_line.movement_id), so spends and claims go through one mechanism. -
Positive lines are the float’s forward outflows that meet three conditions:
- no movement reverses them;
- they have no active line;
- they are either a
spend_payoutwhose spend isreviewer_outcome = 'verified'(spec §8.4), or aclaim_settlement. A claim settlement comes from Task 14’sfloat_claim_settlementlink, and the claim already passed its own verification at its accrual (category_verified).
Each line’s amount is the outflow,
−movement.amount. -
A reversed source is never replenished. Its reversal already credited the float back.
- Reversed before any replenishment: it is excluded, because it has a reversing movement.
- Reversed while its replenishment is pending: paying that replenishment refuses
ErrReplenishmentStale. The holder withdraws it and requests again. - Reversed after its replenishment was paid: the tin now holds that cash twice. So the next replenishment carries a negative line naming the reversal movement. This applies to a
spend_payout_reversalorclaim_settlement_reversalwhose reversed movement has an active line on a paid replenishment, and that has no line of its own. Its amount is−reversal.amount, which is negative. The imprest stays exact.
-
The amount is the sum of the lines, frozen at request time and never edited (
ErrReplenishmentFixed). A sum of zero or less givesErrNothingToReplenish, and the excess waits in the tin for the next outflow or the close-out. -
A rejected, withdrawn or reversed replenishment releases its lines (
released_at). Their movements become eligible again, unless they have since been reversed.
Why: under the imprest system every approved disbursement is restored. §8.4’s “only VERIFIED spends” excludes unverified spends, not approved claims. Frozen lines naming movements make each replenished ringgit traceable to its disbursement’s journal entry, and so to the spend’s receipt or the claim’s documents (§11A.4). Cost if wrong: none found. The negative line exists so a late reversal never over-restores silently.
-
-
PC21 Ruling: Close-out.
- The holder requests it with a refund proof: a verified document uploaded by the holder and not bound elsewhere.
- It needs the float
open,committed = 0andcash_balance > 0. - The amount is frozen at the current
cash_balance. In the same transaction the float entersclosingwithrefund_proof_document_idset (constraintpetty_cash_float_refund_proof_when_closing). - Confirming (entering
payout) posts PC15’s close-out rule. It refusesErrCloseoutStaleifcash_balance ≠ amount(a reversal moved it meanwhile), writes movementcloseoutof−amount, and then setsclosedandclosed_on(constraintpetty_cash_float_closed_shaperequires an empty float). - Withdrawing returns the float to
openand clears the proof. - An empty float (
cash_balance = 0,committed = 0) is closed directly bypettycash.float.manage, with no close-out, proof or posting (CloseEmptyFloat).
Why: this is spec §8.1 (“refund proof is required on entry to closing”), and the ledger refuses a zero-amount leg. Cost if wrong: none found.
-
PC22 Ruling: Reconciliation registers three checks for every float, not only open ones. A closed float must still net to zero.
float_integrity:cash_balance = SUM(float_movement.amount).float_commitment:committed= the sum of the float’s spends whose instance sits in anaccrual-semantic state.float_ledgerhas two parts:- the float account’s base balance in
journal_lineequalscash_balance; - every movement’s journal entry nets exactly the movement’s amount on the float account.
- the float account’s base balance in
- Every finding names the float (or, for a movement, the movement) and the numbers.
Cost if wrong: a failing check blocks period close (Plan 4). That is intended.
-
PC23 Ruling: Permissions.
-
Adopt the Phase 0 placeholder
pettycash.float.disburseas the holder’s permission: create and edit spends, bind receipts, request top-ups, replenishments and close-outs. -
Append five:
Permission Grants pettycash.float.manageopen a float; close an empty one; list every float pettycash.readread floats, spends, top-ups and close-outs you hold, may act on, reviewed, or appear in pettycash.read_allread every one pettycash.spend.reviewuse the review route pettycash.transitionuse the three transition routes -
Authority to decide always comes from the workflow grants; the permission only opens the route.
-
Documents are uploaded through
/v1/documents, which (after the base’s fix) needsdocuments.document.upload. The base seeds it on every filing role, and Plan 8’s module tests grant it to the holder role explicitly (Task 16’s env).GET /v1/documents/:idand…/completeare authenticated-only, and the data rule (documents.MayRead, through the binders) decides.
Why:
constants.yamlis append-only, and adopting the line needs no rewrite. “Disburse” names the holder’s job exactly. Cost if wrong: renaming a permission later is one migration ofrole_permissionrows. -
-
PC24 Ruling: Visibility.
- A spend, top-up or close-out is readable by: its holder or reporter;
pettycash.read_all; any member with an allowed transition on it now; anyone in its decision log; and, for a spend, itsreviewed_by. Anyone else gets 404. - A float is readable by its holder,
pettycash.read_allandpettycash.float.manage. - The binders’
MayReaduses the same functions.
Why: this is Plan 5 R21’s pattern. Cost if wrong: none.
- A spend, top-up or close-out is readable by: its holder or reporter;
-
PC25 Ruling: Idempotency and ETags.
Idempotency-Keyis required on the three transition routes. It is stored in this plan’s own tablepettycash_idempotency_key, keyed(org, member, key). The logic is a copy of Plan 5’s (R28), because contract §3 forbids sharing Plan 5’s table.If-Match: "<revision>"is required on spend and top-up edits, on receipt binding and on review. A missing one is 400; a stale one is 409 (Plan 5 R29).
Cost if wrong: the table grows until Plan 9’s River work adds a pruner.
-
PC26 Ruling: References come from
numbering.Allocatein the creating transaction, per calendar year:Record Scope Prefix Float petty_cash_floatPFSpend petty_cash_spendPSTop-up float_topupPTClose-out float_closeoutPXA float’s
codeis its reference. Cost if wrong: none (§20.1.3). -
PC27 Ruling: Plan 8 registers no provisioning step. Orders
120–129stay unused.- The three graphs are seeded and published at v1 by step 60, through
workflow.RegisterSeededGraphfrompettycash’sinit()(contract §1.3). Existing orgs gain them on the next provisioning run. - A float’s account is made when the float is opened. A chart without
1530refuses to open one (409).
Cost if wrong: an org whose chart lost
1530learns it when opening a float, not from readiness. A later readiness warning is one step. - The three graphs are seeded and published at v1 by step 60, through
-
PC28 Ruling: Plan 9 names (contract §2.2, D2).
pettycash.FloatRef{ID, Code, HolderMemberID, HolderName, AccountID, OpenedOn, ClosedOn}, withHolderName= the holder party’slegal_name, andListFloats(ctx, tx) ([]FloatRef, error)andGetFloat(ctx, tx, id) (FloatRef, error).- The float register’s source kinds are
petty_cash_spend,float_topup,float_closeoutandexpense_claim. - A replenishment is a
float_topup. Plan 9 tells it apart withGetTopUp(ctx, tx, id).Purpose.
Recorded in Notes for later plans. Cost if wrong: Plan 9’s label mapping must learn
float_closeout(PC3). -
PC29 Ruling: Plan 8 records
activity.Recordrows for a float opened or closed, a spend recorded, reviewed, paid or voided, a top-up paid, and a close-out requested or confirmed. Cost if wrong: none (Plan 3 R20). -
PC30 Ruling: The binders apply
pettycash.read_allthemselves. Plan 5’s own binder leavesexpenses.claim.read_allto its route. But the only document read route is Plan 5’s/v1/documents/:id, which knows claim permissions only, so apettycash.read_allholder could never open a spend’s receipt unless the petty-cash binder asksmember.Can(ctx, tx, member, PermissionReadAll). Both binders use the clockdocuments.MayReadpasses (contract §7). Cost if wrong: none; the check widens only to the permission’s own holders. -
PC31 Ruling: Documents are bound under
documents.Lock, taken right after the consumer’s own record (contract §7).documents.BoundElsewhereis called withexcept= this record’s own binding, before binding (contract §1.4).- A spend’s receipt is bound while the spend is not
cancelled-semantic, so a voided (queried) spend’s receipt can back the corrected spend. Anot_reimbursablespend keeps its receipt, because re-using it on a claim would be the double claim the binder exists to stop. - A close-out’s proof is bound unless the close-out was withdrawn.
Cost if wrong: none.
- A spend’s receipt is bound while the spend is not
-
PC32 Ruling: Decision on the placeholder is covered by PC23 (adopted). The permission’s description in
permissions.gosays what it now grants. -
PC33 Ruling (ACCEPTED by the controller): A reversal that would move a
closedfloat is refused (ErrFloatClosed, 409). This covers a dispute upheld after close-out on a claim paid from the tin, on a paid spend, or on a paid top-up. Enteringclosingneedscommitted = 0only.- Why: a closed float must be empty (constraint
petty_cash_float_closed_shape), and there is no tin to put the cash back in. - Cost if wrong: after a float closes, disputes over what it paid can only be dismissed in the system. The cash is settled outside it. This is listed for the user.
- Why: a closed float must be empty (constraint
-
PC34 Ruling (controller ruling 6): A document bound elsewhere is refused with 409 naming the other binding’s kind only, never its reference. This is the base’s rule for the same refusal: the other record may be someone else’s claim. The platform
*BoundElsewhereErrorstill carries fulldocuments.Bindings for tests and logs, but the module’s detail prints kinds only, in the base’s wording (Task 1). Cost if wrong: none.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| File | Task | Responsibility |
|---|---|---|
00150_pettycash_float.sql |
2 | petty_cash_float (stored balances, status, refund proof, one live float per holder); float_movement (append-only, the apply trigger, the balance guard); float_claim_settlement (append-only link, Ruling PC18); pettycash_idempotency_key. |
00151_pettycash_spend.sql |
3 | petty_cash_spend with its draft-only and review-only-while-recorded triggers. |
00152_pettycash_topup_closeout.sql |
3 | float_topup (draft-only trigger), float_replenishment_line (release-only update), float_closeout (append-only). |
New Go packages
Section titled “New Go packages”| Path | Task(s) | Responsibility |
|---|---|---|
internal/platform/pettycash/ |
2, 4–15 | Kinds and graphs (4), rule registry (4), instance helpers (4), floats (5), movements (6), top-ups (7, 8), spends and review (9), spend rules (10), spend transitions (11), replenishment (12), close-outs (13), the claim-settlement API (14), reconciliation (15), idempotency (16). |
internal/platform/pettycash/pettycashtest/ |
2, 4, 5, 8, 9, 11 | The test world: expensestest.Env plus the Cashier and delegation (2), UseRules (4), Float (5), Fund (8), Receipt, DraftSpend (9), RecordedSpend, VerifiedSpend, PaidSpend (11). |
internal/modules/pettycash/ |
16–18 | Permissions, boot (posting rules), routes, handlers, error mapping. |
Modified
Section titled “Modified”| Path | Task | Change |
|---|---|---|
internal/kernel/db/retention_test.go |
2, 3 | Append Plan 8’s tables: three amendable, four appendOnly, one operational. |
internal/gen/sqlc/models.go |
2, 3 | Regenerated. |
internal/platform/expenses/{settlement,posting,transition,errors}.go + payout_test.go, settlement_test.go, new pettycash_settlement_test.go |
14 | Contract §2.2’s hook. |
internal/modules/expenses/errors.go (one case), transition_handlers_test.go (one test) |
14 | Ruling PC2. |
constants.yaml (+ generated vocab) |
16 | Five pettycash.* permissions appended. |
api/openapi.yaml (+ internal/gen/api) |
16, 17, 18 | /pettycash/…. |
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go |
16 | Compose the module. |
docs/ROADMAP.md |
19 | Tick Plan 8’s three lines. |
Task 1: Confirm the branch sits on the built base
Section titled “Task 1: Confirm the branch sits on the built base”No code. The base (phase1-wave3) is built, and every symbol this plan calls was read from ec45790. The Reconciliation record at the end lists each one as built. This task makes sure the code under the executor is that code. No task marked “B” in Parallel groups starts before this task is committed. Since the controller’s rulings, it also reconciles three base changes still being built: taxrecord, the document-route permission, and the kind-only 409.
Files:
-
Modify:
docs/superpowers/plans/2026-09-22-plan-8-petty-cash.md(only if Step 2 finds a difference) -
Step 1: Rebase. The user resets
plan-8-pettycashontophase1-wave3before building. If that has not happened, rungit fetch && git rebase phase1-wave3, or rebase ontomainonce the base has merged. The user merges; you only rebase this branch. Then rungit merge-base --is-ancestor ec45790 HEADand expect exit status 0. -
Step 2: Spot-check the record. Open each file named in the Reconciliation record and confirm each row still holds: same name, same signature. Also run
git grep -n 'float_closeout\|pettycash\.' plan-7-advancesto confirm Plan 7 has not claimed Ruling PC3’s kind or anypettycash.*name.internal/platform/taxrecord(the base’s extraction fromexpenses, not yet onec45790; controller ruling 3). Record its record type, its field names, its validation function(s), its problem/error type and rule codes, and the basis constants. Then edit Task 9’sTaxRecordmapping,checkTaxRecordandTestASpendCarriesTheTaxRecordto call it exactly. Confirm that it imports nothing fromexpenses, sopettycashmay import it. If the package has not landed when Task 9 starts, stop and report: do not copy Plan 5’s line rules intopettycash.- The document routes (controller ruling 5):
POST /v1/documentsrequiresdocuments.document.upload, seeded on every filing role.GET /v1/documents/:idandPOST …/completeare authenticated-only, anddocuments.MayReaddecides. Record the constant’s Go name for Task 16’s env. - The bound-elsewhere 409 (controller ruling 6) names only the kind. Record the base’s detail wording, and use the same wording in Tasks 17–18’s
*BoundElsewhereErrormapping. - If nothing differs, there is nothing to commit. Say so in the report.
- If a row changed, edit the affected task’s Interfaces and code blocks to name the symbol as built, record the change in the appendix, and commit:
git add docs/superpowers/plans/2026-09-22-plan-8-petty-cash.mdgit commit -m "Plan 8: reconcile task briefs with the phase1-wave3 base as built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"If a needed symbol is missing, do not invent it. Add a Ruling after PC33 stating a workaround inside Plan 8’s ownership, or stop and report to the user.
Task 2: Migration 00150 — floats, movements, idempotency; the test world
Section titled “Task 2: Migration 00150 — floats, movements, idempotency; the test world”Needs: the branch’s base. It uses no base-only symbol, so it may start beside Task 1.
Files:
- Create:
db/migrations/00150_pettycash_float.sql,internal/platform/pettycash/doc.go,internal/platform/pettycash/pettycashtest/env.go - Modify:
internal/kernel/db/retention_test.go, thenmake generate - Test:
internal/platform/pettycash/float_schema_test.go,internal/platform/pettycash/pettycashtest/env_test.go
Interfaces:
-
Consumes:
expensestest.New,expensestest.Env(H,DB,Ctx,Org,Clock,Owner,Staff,OwnerSeat,StaffSeat,Store,Tx,TxErr,Member,Document,Count,UsePoster,Channel,PayoutProfile);workflow.CreateDelegation,workflow.DelegationInput;chart.CreateAccount,chart.GetAccountByCode;ledger.Post(for a journal entry an FK can point at). -
Produces the four tables every later task uses (columns exactly as in the SQL below), and:
package pettycash // doc.go// Package pettycash is spec §8: floats, the movement ledger, spends,// top-ups, replenishments and close-outs, and the settlement API// internal/platform/expenses calls (wave-3 contract §2.2). It never// imports internal/platform/expenses.package pettycashtestvar T0 = expensestest.T0 // 2026-09-21 09:00 UTCvar Today = expensestest.Today // 2026-09-21type Env struct {*expensestest.Env// Cashier reports to the root seat, holds a float in the claim tests,// and pays expense claims by an expense_claim delegation from Owner// valid from Today (Ruling PC18).Cashier, CashierSeat uuid.UUID}func New(t *testing.T) *Env // posting is reset to nil (expensestest.New); call UseRules (Task 4)func (e *Env) FloatHeader(t *testing.T) chart.Account // the 1530 header -
Step 1: Write migration 00150.
-- +goose Up
-- FLOATS (spec 8.1). A float is a cash account one member is personally-- accountable for. Its balances are STORED, because the overdraw refusal-- needs a row to lock (spec 8.1, Ruling PC12). cash_balance is moved only-- by float_movement's trigger below; committed only by Go, under this-- row's lock, in the spend transition that commits or releases it.CREATE TABLE petty_cash_float ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL, holder_member_id UUID NOT NULL, account_id UUID NOT NULL, currency TEXT NOT NULL CONSTRAINT petty_cash_float_currency_code CHECK (currency ~ '^[A-Z]{3}$'), cash_balance NUMERIC(19,4) NOT NULL DEFAULT 0, committed NUMERIC(19,4) NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'open' CONSTRAINT petty_cash_float_status_known CHECK (status IN ('open','closing','closed')), refund_proof_document_id UUID, opened_on DATE NOT NULL, closed_on DATE, opened_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT petty_cash_float_tenant_id UNIQUE (org_id, id), CONSTRAINT petty_cash_float_code_once UNIQUE (org_id, code), CONSTRAINT petty_cash_float_account_once UNIQUE (org_id, account_id), CONSTRAINT petty_cash_float_committed_nonnegative CHECK (committed >= 0), -- available = cash_balance - committed (spec 8.1): what the overdraw -- check tests. With the line above it also keeps cash_balance >= 0. CONSTRAINT petty_cash_float_available_nonnegative CHECK (cash_balance - committed >= 0), -- Refund proof is required on entry to closing (spec 8.1). CONSTRAINT petty_cash_float_refund_proof_when_closing CHECK (status <> 'closing' OR refund_proof_document_id IS NOT NULL), -- A closed float is dated and empty (Ruling PC21). CONSTRAINT petty_cash_float_closed_shape CHECK ((status = 'closed') = (closed_on IS NOT NULL) AND (status <> 'closed' OR (cash_balance = 0 AND committed = 0))), CONSTRAINT petty_cash_float_dates_ordered CHECK (closed_on IS NULL OR closed_on >= opened_on), CONSTRAINT petty_cash_float_holder_fk FOREIGN KEY (org_id, holder_member_id) REFERENCES organization_member (org_id, id), CONSTRAINT petty_cash_float_opened_by_fk FOREIGN KEY (org_id, opened_by) REFERENCES organization_member (org_id, id), CONSTRAINT petty_cash_float_account_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id), CONSTRAINT petty_cash_float_refund_proof_fk FOREIGN KEY (org_id, refund_proof_document_id) REFERENCES document (org_id, id));-- One live float per holder (Ruling PC10): "the paying holder's float"-- (spec 8.4) must name one row.CREATE UNIQUE INDEX petty_cash_float_one_live_per_holder ON petty_cash_float (org_id, holder_member_id) WHERE status <> 'closed';
-- MOVEMENTS (spec 8.1): the auditable source of the stored balance.-- cash_balance = SUM(amount) is made a database fact by the trigger below-- (Ruling PC12). Every movement names the journal entry it mirrors-- (Ruling PC14) and, for a reversal, the movement it reverses.CREATE TABLE float_movement ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), float_id UUID NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, amount NUMERIC(19,4) NOT NULL, kind TEXT NOT NULL CONSTRAINT float_movement_kind_known CHECK (kind IN ('topup','topup_reversal','spend_payout','spend_payout_reversal', 'claim_settlement','claim_settlement_reversal','closeout')), source_type TEXT NOT NULL, source_id UUID NOT NULL, journal_entry_id UUID NOT NULL, reverses_movement_id UUID, recorded_by UUID NOT NULL, CONSTRAINT float_movement_tenant_id UNIQUE (org_id, id), CONSTRAINT float_movement_source_matches CHECK ( (kind IN ('topup','topup_reversal') AND source_type = 'float_topup') OR (kind IN ('spend_payout','spend_payout_reversal') AND source_type = 'petty_cash_spend') OR (kind IN ('claim_settlement','claim_settlement_reversal') AND source_type = 'expense_claim') OR (kind = 'closeout' AND source_type = 'float_closeout')), CONSTRAINT float_movement_sign CHECK (amount <> 0 AND (amount > 0) = (kind IN ('topup','spend_payout_reversal','claim_settlement_reversal'))), CONSTRAINT float_movement_reversal_shape CHECK ((reverses_movement_id IS NOT NULL) = (kind IN ('topup_reversal','spend_payout_reversal','claim_settlement_reversal'))), CONSTRAINT float_movement_entry_once UNIQUE (org_id, journal_entry_id), CONSTRAINT float_movement_reversed_once UNIQUE (org_id, reverses_movement_id), CONSTRAINT float_movement_float_fk FOREIGN KEY (org_id, float_id) REFERENCES petty_cash_float (org_id, id), CONSTRAINT float_movement_entry_fk FOREIGN KEY (org_id, journal_entry_id) REFERENCES journal_entry (org_id, id), CONSTRAINT float_movement_reverses_fk FOREIGN KEY (org_id, reverses_movement_id) REFERENCES float_movement (org_id, id), CONSTRAINT float_movement_recorded_by_fk FOREIGN KEY (org_id, recorded_by) REFERENCES organization_member (org_id, id));CREATE INDEX float_movement_by_float ON float_movement (org_id, float_id, occurred_at DESC, id DESC);CREATE INDEX float_movement_by_source ON float_movement (org_id, source_type, source_id);
-- The one writer of cash_balance.-- +goose StatementBeginCREATE FUNCTION float_movement_apply() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN UPDATE public.petty_cash_float SET cash_balance = cash_balance + NEW.amount, updated_at = NEW.occurred_at WHERE org_id = NEW.org_id AND id = NEW.float_id; IF NOT FOUND THEN RAISE EXCEPTION 'float % not found for movement %', NEW.float_id, NEW.id USING ERRCODE = '23503', CONSTRAINT = 'float_movement_float_fk'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER float_movement_apply AFTER INSERT ON float_movement FOR EACH ROW EXECUTE FUNCTION float_movement_apply();
-- Anything else that tries to move cash_balance is refused: only the-- trigger above (depth 2) may.-- +goose StatementBeginCREATE FUNCTION petty_cash_float_balance_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF pg_trigger_depth() < 2 THEN RAISE EXCEPTION 'float %: cash_balance moves only with a float_movement', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'petty_cash_float_balance_by_movement'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER petty_cash_float_balance_guard BEFORE UPDATE OF cash_balance ON petty_cash_float FOR EACH ROW WHEN (NEW.cash_balance IS DISTINCT FROM OLD.cash_balance) EXECUTE FUNCTION petty_cash_float_balance_guard();
-- CLAIMS SETTLED FROM THE TIN (Ruling PC18). Which float paid which claim-- settlement: Plan 8's own link, so Plan 5's 00073 is untouched. The-- settlement row itself names the float's account as credit_account_id.CREATE TABLE float_claim_settlement ( org_id UUID NOT NULL REFERENCES organization (id), settlement_id UUID NOT NULL, float_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT float_claim_settlement_pk PRIMARY KEY (org_id, settlement_id), CONSTRAINT float_claim_settlement_settlement_fk FOREIGN KEY (org_id, settlement_id) REFERENCES expense_claim_settlement (org_id, id), CONSTRAINT float_claim_settlement_float_fk FOREIGN KEY (org_id, float_id) REFERENCES petty_cash_float (org_id, id));CREATE INDEX float_claim_settlement_by_float ON float_claim_settlement (org_id, float_id);
-- IDEMPOTENCY KEYS (Ruling PC25). Operational: prunable. Plan 5's table-- shape, Plan 8's own namespace (wave-3 contract §3).CREATE TABLE pettycash_idempotency_key ( org_id UUID NOT NULL REFERENCES organization (id), member_id UUID NOT NULL, key TEXT NOT NULL CONSTRAINT pettycash_idempotency_key_length CHECK (length(key) BETWEEN 1 AND 255), request_hash TEXT NOT NULL CONSTRAINT pettycash_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 pettycash_idempotency_pk PRIMARY KEY (org_id, member_id, key), CONSTRAINT pettycash_idempotency_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id));
ALTER TABLE petty_cash_float ENABLE ROW LEVEL SECURITY;ALTER TABLE petty_cash_float FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON petty_cash_float USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE float_movement ENABLE ROW LEVEL SECURITY;ALTER TABLE float_movement FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON float_movement USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE float_claim_settlement ENABLE ROW LEVEL SECURITY;ALTER TABLE float_claim_settlement FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON float_claim_settlement USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE pettycash_idempotency_key ENABLE ROW LEVEL SECURITY;ALTER TABLE pettycash_idempotency_key FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON pettycash_idempotency_key USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON petty_cash_float FROM erp_app;REVOKE UPDATE, DELETE ON float_movement FROM erp_app;REVOKE UPDATE, DELETE ON float_claim_settlement FROM erp_app;
-- +goose DownDROP TABLE pettycash_idempotency_key;DROP TABLE float_claim_settlement;DROP TABLE float_movement;DROP TABLE petty_cash_float;DROP FUNCTION petty_cash_float_balance_guard();DROP FUNCTION float_movement_apply();-
Step 2: Retention. Append to the lists in
retention_test.go, each with its comment:amendable+=petty_cash_float: “Plan 8: a float and its stored balances (spec 8.1). Balances change only with a movement (trigger) or under the float lock in a spend transition; closed, never deleted.”appendOnly+=float_movement: “Plan 8: the auditable source of cash_balance (spec 8.1); a correction is a reversing movement.”appendOnly+=float_claim_settlement: “Plan 8: which float paid which claim settlement (Ruling PC18), frozen.”operational+=pettycash_idempotency_key: “Plan 8: replay protection for transition requests.”
Then run
make generate. -
Step 3: Write the fixture —
pettycashtest/env.go.New(t):ex := expensestest.New(t).cashier, seat := ex.Member(t, "Cashier", &ex.OwnerSeat).- In one
ex.Tx, callworkflow.CreateDelegation(ctx, tx, ex.Clock, workflow.DelegationInput{Delegator: ex.Owner, Delegate: cashier, Kind: ptr("expense_claim"), ValidFrom: Today, Reason: "Cashier pays claims from the tin"}). - Return
&Env{Env: ex, Cashier: cashier, CashierSeat: seat}.
FloatHeader:chart.GetAccountByCode(ctx, tx, "1530")insidee.Tx. -
Step 4: Write the failing tests —
float_schema_test.go(package pettycash_test). The helpers are local to this file and prefixedschema:schemaAccount(t, e, code)creates a postable asset leaf under 1530 withchart.CreateAccountand noOwnerModule. The base need not be built yet, and the FK only needs an account.schemaFloat(t, e, holder, status)inserts a row aserp_appine.Tx, with iduuid.New()and code"PF-T-" + short.schemaEntry(t, e)posts one manual entry throughledger.Post: Dr 1510 / Cr 1520,1.00,PostedBy: &e.Owner. It returns the entry id, for the movement FK.schemaMove(ctx, tx, e, floatID, kind, sourceType, amount, reverses)inserts one movement and returns its error.
func TestOnlyAMovementMovesTheCashBalance(t *testing.T) { // PRESENCE: an open float at 0/0; a 'topup' movement of +100.0000 -> // cash_balance 100.0000 in the same transaction (read back). // ABSENCE: UPDATE petty_cash_float SET cash_balance = 60 -> 23514 naming // petty_cash_float_balance_by_movement; UPDATE ... SET committed = 10 // succeeds (committed is not guarded) and so does SET updated_at = now().}func TestAFloatNeverGoesBelowWhatItHasCommitted(t *testing.T) { // Float funded +100. PRESENCE: committed = 100 succeeds. Then, one each // (23514, constraint named): committed = 100.0001 -> // petty_cash_float_available_nonnegative; committed = -1 -> // petty_cash_float_committed_nonnegative; with committed = 0 a // 'spend_payout' movement of -100.0001 -> petty_cash_float_available_nonnegative // (raised from inside the apply trigger, so the INSERT fails).}func TestAHolderHasOneLiveFloat(t *testing.T) { // PRESENCE: an open float for Staff inserts. A second open (or closing) // float for Staff -> 23505 naming petty_cash_float_one_live_per_holder. // After the first is closed (cash 0, closed_on today), the second inserts.}func TestAClosedFloatIsEmptyAndDated(t *testing.T) { // PRESENCE: status closed, closed_on today, 0/0 inserts. One each: // closed with cash 50 (via a movement first) -> petty_cash_float_closed_shape; // closed without closed_on -> petty_cash_float_closed_shape; open with a // closed_on -> petty_cash_float_closed_shape; closing without a refund // proof -> petty_cash_float_refund_proof_when_closing; closed_on before // opened_on -> petty_cash_float_dates_ordered.}func TestMovementsAreAppendOnlyAndConsistent(t *testing.T) { // PRESENCE: a 'topup' +10 and its 'topup_reversal' -10 (reverses the // first) insert, each on its own schemaEntry. Then, one each (23514 // unless noted): 'topup' with -10 -> float_movement_sign; 'topup' with // source_type 'petty_cash_spend' -> float_movement_source_matches; // 'topup_reversal' without reverses_movement_id -> // float_movement_reversal_shape; a second reversal of the same movement // -> 23505 float_movement_reversed_once; a movement reusing a journal // entry -> 23505 float_movement_entry_once; UPDATE and DELETE of a // movement as erp_app -> 42501.}func TestFloatRecordsCannotBeDeleted(t *testing.T) // PRESENCE: UPDATE committed ok; DELETE -> 42501func TestIdempotencyKeysArePrunable(t *testing.T) // erp_app DELETE succeedsfunc TestASettlementIsLinkedToOneFloat(t *testing.T) { // A settlement row inserted as admin (Plan 5's world, a hand-built // snapshot as in Plan 5's claims_schema_test settle helper). PRESENCE: a // float_claim_settlement row inserts. A second row for the same // settlement -> 23505 float_claim_settlement_pk; UPDATE and DELETE -> // 42501.}pettycashtest/env_test.go:
func TestTheWorldHasACashierWhoMayPayClaims(t *testing.T) { // New: Cashier's primary seat reports to OwnerSeat; exactly one active // delegation Owner -> Cashier, kind expense_claim, from Today // (workflow.ActiveDelegationsTo(ctx, tx, Cashier, "expense_claim", Today)); // FloatHeader(t) has code "1530", IsPostable false, Type asset; // chart.ResolveControl(ctx, tx, "petty_cash_ap") is 2130.}-
Step 5: Run and watch fail, then run
make generate. Then rungo test ./internal/platform/pettycash/... ./internal/kernel/db/ -count=1and expect PASS. The search-path guard sees the two new functions, the constraint guard sees the new names, and the tenant-FK guard sees the composite keys. -
Step 6: Sabotage.
- Change the guard’s
pg_trigger_depth() < 2to< 1.TestOnlyAMovementMovesTheCashBalancegoes red (the direct update is accepted). Quote the output, then restore. - Drop
WITH CHECKfromfloat_movement’s policy. The repo’s RLS guard goes red. Quote it, then restore.
- Change the guard’s
-
Step 7: Commit.
git add db/migrations/00150_pettycash_float.sql internal/kernel/db/retention_test.go internal/gen/sqlc \ internal/platform/pettycashgit commit -m "Add petty-cash floats and the movement ledger that alone moves their cash balance
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 3: Migrations 00151–00152 — spends, top-ups, replenishment lines, close-outs
Section titled “Task 3: Migrations 00151–00152 — spends, top-ups, replenishment lines, close-outs”Needs: Task 2. It runs beside Task 4 and uses no base symbol, because its tests publish their own tiny graph.
Files:
- Create:
db/migrations/00151_pettycash_spend.sql,db/migrations/00152_pettycash_topup_closeout.sql - Modify:
internal/kernel/db/retention_test.go, thenmake generate - Test:
internal/platform/pettycash/documents_schema_test.go
Interfaces:
-
Consumes: Task 2’s tables and fixture;
workflow.PublishGraph,workflow.Start,workflow.Transition;posting.ResetForTest,posting.Register. -
Produces the four tables (columns as below) that Tasks 7–13 use verbatim.
-
Step 1: Write migration 00151.
-- +goose Up
-- The semantic of an instance's current state: what the guards below read.-- +goose StatementBeginCREATE FUNCTION pettycash_instance_semantic(p_org UUID, p_instance UUID) RETURNS text LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT s.semantic FROM public.workflow_instance i 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 i.org_id = p_org AND i.id = p_instance$$;-- +goose StatementEnd
-- SPENDS (spec 8.2): two-phase. recorded accrues and commits the float;-- spend_paid pays. The workflow state lives on the instance.CREATE TABLE petty_cash_spend ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, float_id UUID NOT NULL, holder_member_id UUID NOT NULL, instance_id UUID NOT NULL, expense_date DATE NOT NULL, description TEXT NOT NULL CONSTRAINT petty_cash_spend_description_present CHECK (btrim(description) <> '' AND length(description) <= 500), -- The tax record (Ruling PC16, spec 6.7), validated in Go by -- internal/platform/taxrecord; the CHECKs below mirror 00072's on -- expense_claim_line, each named after its original. supplier_name TEXT CONSTRAINT petty_cash_spend_supplier_length CHECK (supplier_name IS NULL OR length(supplier_name) <= 200), supplier_tin TEXT, einvoice_uuid TEXT, einvoice_validated_at TIMESTAMPTZ, document_basis TEXT, relaxation_reason TEXT, amount NUMERIC(19,4) NOT NULL CONSTRAINT petty_cash_spend_amount_positive CHECK (amount > 0), currency TEXT NOT NULL CONSTRAINT petty_cash_spend_currency_code CHECK (currency ~ '^[A-Z]{3}$'), expense_account_id UUID NOT NULL, tax_code_id UUID, receipt_document_id UUID, -- Ruling PC7: strings; NULL = not yet reviewed. Only 'verified' pays. reviewer_outcome TEXT CONSTRAINT petty_cash_spend_outcome_known CHECK (reviewer_outcome IN ('queried','verified','not_reimbursable')), reviewed_by UUID, reviewed_at TIMESTAMPTZ, revision INT NOT NULL DEFAULT 1 CONSTRAINT petty_cash_spend_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT petty_cash_spend_tenant_id UNIQUE (org_id, id), CONSTRAINT petty_cash_spend_reference_once UNIQUE (org_id, reference), CONSTRAINT petty_cash_spend_instance_once UNIQUE (org_id, instance_id), CONSTRAINT petty_cash_spend_review_shape CHECK ((reviewer_outcome IS NULL) = (reviewed_by IS NULL) AND (reviewer_outcome IS NULL) = (reviewed_at IS NULL)), CONSTRAINT petty_cash_spend_text_language CHECK (description !~ <cjk> AND coalesce(supplier_name, '') !~ <cjk> AND coalesce(relaxation_reason, '') !~ <cjk>), -- Mirrors expense_claim_line_basis_known (00072), without einvoice_employee (Ruling PC16). CONSTRAINT petty_cash_spend_basis_known CHECK (document_basis IS NULL OR document_basis IN ('einvoice_company','foreign_supplier','relaxation_receipt')), -- Mirrors expense_claim_line_einvoice_identifiers (00072). CONSTRAINT petty_cash_spend_einvoice_identifiers CHECK (document_basis IS DISTINCT FROM 'einvoice_company' OR (supplier_tin IS NOT NULL AND einvoice_uuid IS NOT NULL)), -- Mirrors expense_claim_line_relaxation_reason (00072). CONSTRAINT petty_cash_spend_relaxation_reason CHECK (document_basis IS DISTINCT FROM 'relaxation_receipt' OR btrim(coalesce(relaxation_reason, '')) <> ''), -- Mirrors expense_claim_line_tin_shape (00072). CONSTRAINT petty_cash_spend_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 (00072). CONSTRAINT petty_cash_spend_einvoice_uuid_shape CHECK (einvoice_uuid IS NULL OR einvoice_uuid ~ '^[A-Z0-9]{10,64}$'), CONSTRAINT petty_cash_spend_float_fk FOREIGN KEY (org_id, float_id) REFERENCES petty_cash_float (org_id, id), CONSTRAINT petty_cash_spend_holder_fk FOREIGN KEY (org_id, holder_member_id) REFERENCES organization_member (org_id, id), CONSTRAINT petty_cash_spend_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT petty_cash_spend_account_fk FOREIGN KEY (org_id, expense_account_id) REFERENCES account (org_id, id), CONSTRAINT petty_cash_spend_tax_code_fk FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id), CONSTRAINT petty_cash_spend_receipt_fk FOREIGN KEY (org_id, receipt_document_id) REFERENCES document (org_id, id), CONSTRAINT petty_cash_spend_reviewed_by_fk FOREIGN KEY (org_id, reviewed_by) REFERENCES organization_member (org_id, id));CREATE INDEX petty_cash_spend_by_float ON petty_cash_spend (org_id, float_id, created_at DESC, id DESC);CREATE INDEX petty_cash_spend_by_receipt ON petty_cash_spend (org_id, receipt_document_id) WHERE receipt_document_id IS NOT NULL;-- (ENABLE + FORCE ROW LEVEL SECURITY; CREATE POLICY org_isolation USING +-- WITH CHECK — write it out, as in 00150.)REVOKE DELETE ON petty_cash_spend FROM erp_app;
-- What was recorded is what accrued: money fields change only in draft.-- +goose StatementBeginCREATE FUNCTION petty_cash_spend_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.pettycash_instance_semantic(NEW.org_id, NEW.instance_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'petty-cash spend % is not a draft: what it records cannot change', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'petty_cash_spend_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER petty_cash_spend_draft_guard BEFORE UPDATE OF float_id, expense_date, description, supplier_name, supplier_tin, einvoice_uuid, einvoice_validated_at, document_basis, relaxation_reason, amount, currency, expense_account_id, tax_code_id, receipt_document_id ON petty_cash_spend FOR EACH ROW EXECUTE FUNCTION petty_cash_spend_draft_guard();
-- The reviewer's outcome is decided while the spend is recorded, and only-- then (Ruling PC8): after payment it is the evidence the pay gate read.-- +goose StatementBeginCREATE FUNCTION petty_cash_spend_review_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.pettycash_instance_semantic(NEW.org_id, NEW.instance_id) IS DISTINCT FROM 'accrual' THEN RAISE EXCEPTION 'petty-cash spend % is not awaiting review', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'petty_cash_spend_review_while_recorded'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER petty_cash_spend_review_guard BEFORE UPDATE OF reviewer_outcome, reviewed_by, reviewed_at ON petty_cash_spend FOR EACH ROW EXECUTE FUNCTION petty_cash_spend_review_guard();
-- +goose DownDROP TABLE petty_cash_spend;DROP FUNCTION petty_cash_spend_review_guard();DROP FUNCTION petty_cash_spend_draft_guard();DROP FUNCTION pettycash_instance_semantic(UUID, UUID);- Step 2: Write migration 00152.
-- +goose Up
-- TOP-UPS (spec 8.3) and REPLENISHMENTS (Ruling PC20): cash into the tin.CREATE TABLE float_topup ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, float_id UUID NOT NULL, requested_by UUID NOT NULL, instance_id UUID NOT NULL, purpose TEXT NOT NULL CONSTRAINT float_topup_purpose_known CHECK (purpose IN ('topup','replenishment')), amount NUMERIC(19,4) NOT NULL CONSTRAINT float_topup_amount_positive CHECK (amount > 0), currency TEXT NOT NULL CONSTRAINT float_topup_currency_code CHECK (currency ~ '^[A-Z]{3}$'), reason TEXT NOT NULL CONSTRAINT float_topup_reason_present CHECK (btrim(reason) <> '' AND length(reason) <= 500), revision INT NOT NULL DEFAULT 1 CONSTRAINT float_topup_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT float_topup_tenant_id UNIQUE (org_id, id), CONSTRAINT float_topup_reference_once UNIQUE (org_id, reference), CONSTRAINT float_topup_instance_once UNIQUE (org_id, instance_id), CONSTRAINT float_topup_reason_language CHECK (reason !~ <cjk>), CONSTRAINT float_topup_float_fk FOREIGN KEY (org_id, float_id) REFERENCES petty_cash_float (org_id, id), CONSTRAINT float_topup_requested_by_fk FOREIGN KEY (org_id, requested_by) REFERENCES organization_member (org_id, id), CONSTRAINT float_topup_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id));CREATE INDEX float_topup_by_float ON float_topup (org_id, float_id, created_at DESC, id DESC);
-- +goose StatementBeginCREATE FUNCTION float_topup_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.pettycash_instance_semantic(NEW.org_id, NEW.instance_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'top-up % is no longer a request: its amount and reason cannot change', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'float_topup_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER float_topup_draft_guard BEFORE UPDATE OF float_id, amount, currency, reason, purpose ON float_topup FOR EACH ROW EXECUTE FUNCTION float_topup_draft_guard();
-- The disbursements a replenishment restores, frozen at request (Ruling-- PC20): each line names the float movement it restores — a VERIFIED-- spend's payout or a claim settled from the tin (positive), or the-- reversal of an already-replenished one (negative). A line is released,-- never deleted, when its top-up is rejected, withdrawn or reversed; while-- active, a movement is on one replenishment only.CREATE TABLE float_replenishment_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), topup_id UUID NOT NULL, movement_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT float_replenishment_line_amount_nonzero CHECK (amount <> 0), created_at TIMESTAMPTZ NOT NULL, released_at TIMESTAMPTZ, CONSTRAINT float_replenishment_line_topup_fk FOREIGN KEY (org_id, topup_id) REFERENCES float_topup (org_id, id), CONSTRAINT float_replenishment_line_movement_fk FOREIGN KEY (org_id, movement_id) REFERENCES float_movement (org_id, id));CREATE UNIQUE INDEX float_replenishment_line_movement_once ON float_replenishment_line (org_id, movement_id) WHERE released_at IS NULL;CREATE INDEX float_replenishment_line_by_topup ON float_replenishment_line (org_id, topup_id);
-- CLOSE-OUTS (spec 8.1, Ruling PC3/PC21): the refund proof and the amount-- banked, frozen. Append-only: the state lives on the instance and the float.CREATE TABLE float_closeout ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, float_id UUID NOT NULL, requested_by UUID NOT NULL, instance_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT float_closeout_amount_positive CHECK (amount > 0), currency TEXT NOT NULL CONSTRAINT float_closeout_currency_code CHECK (currency ~ '^[A-Z]{3}$'), proof_document_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT float_closeout_tenant_id UNIQUE (org_id, id), CONSTRAINT float_closeout_reference_once UNIQUE (org_id, reference), CONSTRAINT float_closeout_instance_once UNIQUE (org_id, instance_id), CONSTRAINT float_closeout_float_fk FOREIGN KEY (org_id, float_id) REFERENCES petty_cash_float (org_id, id), CONSTRAINT float_closeout_requested_by_fk FOREIGN KEY (org_id, requested_by) REFERENCES organization_member (org_id, id), CONSTRAINT float_closeout_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT float_closeout_proof_fk FOREIGN KEY (org_id, proof_document_id) REFERENCES document (org_id, id));CREATE INDEX float_closeout_by_float ON float_closeout (org_id, float_id, created_at DESC);CREATE INDEX float_closeout_by_proof ON float_closeout (org_id, proof_document_id);
-- (RLS: ENABLE, FORCE, org_isolation USING + WITH CHECK on all three —-- write each out.)REVOKE DELETE ON float_topup FROM erp_app;REVOKE UPDATE, DELETE ON float_replenishment_line FROM erp_app;GRANT UPDATE (released_at) ON float_replenishment_line TO erp_app;REVOKE UPDATE, DELETE ON float_closeout FROM erp_app;
-- +goose DownDROP TABLE float_closeout;DROP TABLE float_replenishment_line;DROP TABLE float_topup;DROP FUNCTION float_topup_draft_guard();-
Step 3: Retention. Append:
amendable+=petty_cash_spend,float_topup, with the comment “Plan 8: petty-cash documents (spec 8.2–8.3). Money fields frozen outside draft by trigger.”appendOnly+=float_closeout: “Plan 8: a close-out’s refund proof and amount, frozen (spec 8.1).”appendOnly+=float_replenishment_line: “Plan 8: the disbursements a replenishment restores, each naming its float movement, frozen at request (Ruling PC20). erp_app holds UPDATE on released_at only; has_table_privilege UPDATE is false.” The guard’samendableloop requires table-level UPDATE, and itsappendOnlyloop requires it absent, which a column grant satisfies (retention_test.go).
Then run
make generate. -
Step 4: Write the failing tests —
documents_schema_test.go.- A
nopPosteris registered withposting.ResetForTest(); posting.Register(nopPoster{}); t.Cleanup(posting.ResetForTest). - A test-only graph
pc_schema_probeis published withworkflow.PublishGraph:draft(draft) →recorded(accrual, controlpetty_cash_ap) →paid(payout, terminal), with reporter gates throughout, plusrecorded → voided(cancelled, reason, reverses). - Helpers
probeInstance(t, e)(Start in its own tx) andprobeMove(t, e, inst, from, to)(Transition in its own tx) are local to this file.
- A
func TestASpendsRecordFreezesOnceRecorded(t *testing.T) { // A float (Task 2's schemaFloat) and a spend row inserted as erp_app on a // fresh probe instance. PRESENCE: UPDATE amount, description and // receipt_document_id succeed while draft. probeMove(draft -> recorded). // Then each -> 23514 petty_cash_spend_is_draft: UPDATE amount; UPDATE // expense_account_id; UPDATE receipt_document_id. UPDATE revision = // revision + 1 still succeeds (not guarded).}func TestTheOutcomeIsDecidedOnlyWhileRecorded(t *testing.T) { // PRESENCE: in draft, UPDATE reviewer_outcome = 'verified' (with // reviewed_by, reviewed_at) -> 23514 petty_cash_spend_review_while_recorded. // Move to recorded: the same UPDATE succeeds; = 'approved' or = '1' -> // petty_cash_spend_outcome_known; outcome without reviewed_by -> // petty_cash_spend_review_shape. Move to paid: UPDATE reviewer_outcome = // 'not_reimbursable' -> review_while_recorded.}func TestSpendTextIsARecord(t *testing.T) // description "午餐" -> petty_cash_spend_text_language; amount 0 -> petty_cash_spend_amount_positive (PRESENCE: "Lunch" inserts)func TestASpendsTaxRecordShapeIsEnforcedByTheDatabase(t *testing.T) { // Mirrors Plan 5's TestTheTaxRecordShapeIsEnforcedByTheDatabase. PRESENCE: // einvoice_company with TIN "C20880050010" and uuid // "F9D425P6DS7D8IU0000000000A" inserts. One each (23514, named): // einvoice_company without einvoice_uuid -> petty_cash_spend_einvoice_identifiers; // relaxation_receipt without reason -> petty_cash_spend_relaxation_reason; // TIN "12345" -> petty_cash_spend_tin_shape; uuid "abc" -> // petty_cash_spend_einvoice_uuid_shape; einvoice_employee -> // petty_cash_spend_basis_known; relaxation_reason "无收据" -> // petty_cash_spend_text_language. After draft -> recorded, UPDATE // supplier_tin -> petty_cash_spend_is_draft.}func TestATopUpRequestFreezesOnceReviewed(t *testing.T) { // A float_topup on a probe instance. PRESENCE: UPDATE amount in draft. // probeMove(draft -> recorded): UPDATE amount -> 23514 float_topup_is_draft; // reason "补充" at insert -> float_topup_reason_language; purpose 'other' // -> float_topup_purpose_known.}func TestAMovementIsOnOneLiveReplenishment(t *testing.T) { // Movements from Task 2's schemaMove helper. PRESENCE: a line (topup A, // movement M, 30.00) inserts. A second active line for M (topup B) -> // 23505 float_replenishment_line_movement_once. UPDATE the first SET // released_at = now() succeeds; now B's line inserts. A line of 0 -> // float_replenishment_line_amount_nonzero; a negative line (-5.00) // inserts. UPDATE amount -> 42501 (column privilege); DELETE -> 42501.}func TestCloseoutsAreAppendOnly(t *testing.T) // PRESENCE insert; UPDATE amount -> 42501; DELETE -> 42501; amount 0 -> float_closeout_amount_positivefunc TestPettyCashDocumentsCannotBeDeleted(t *testing.T) // DELETE on petty_cash_spend, float_topup -> 42501 after PRESENCE of UPDATE revision-
Step 5: Run. Run
make generate, thengo test ./internal/platform/pettycash/... ./internal/kernel/db/ -count=1and expect PASS. -
Step 6: Sabotage. Remove
receipt_document_idfrom the draft trigger’s column list.TestASpendsRecordFreezesOnceRecordedgoes red. Quote the output, then restore. -
Step 7: Commit.
git add db/migrations/00151_pettycash_spend.sql db/migrations/00152_pettycash_topup_closeout.sql \ internal/kernel/db/retention_test.go internal/gen/sqlc internal/platform/pettycashgit commit -m "Add petty-cash spends, top-ups, replenishment lines and close-outs with draft and review guards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 4: The three seeded graphs, the rule registry, the instance helpers
Section titled “Task 4: The three seeded graphs, the rule registry, the instance helpers”Needs: Task 1 (B: RegisterSeededGraph, CashOnly, RequireSeededGraphValid) and Task 2 (the fixture). It runs beside Task 3.
Files:
- Create:
internal/platform/pettycash/kinds.go(constants, the three graph builders,init()registration),internal/platform/pettycash/rules.go,internal/platform/pettycash/instance.go,internal/platform/pettycash/export_instance_test.go - Modify:
internal/platform/pettycash/pettycashtest/env.go(addUseRules) - Test:
internal/platform/pettycash/kinds_test.go,internal/platform/pettycash/instance_test.go
Interfaces:
-
Consumes:
workflow.RegisterSeededGraph,workflow.Graph,StateSpec,EdgeSpec,GrantSpec, the gate and grantee constants,workflow.AccrualFirst,workflow.CashOnly,workflow.ValidateGraph,workflow.Latest,workflow.Get,workflow.Load,workflow.AvailableTransitions,workflow.Decisions;workflowtest.RequireSeededGraphValid;expenses.Rules()(inpettycashtestonly). -
Produces:
const (KindSpend = "petty_cash_spend"KindTopUp = "float_topup"KindCloseout = "float_closeout" // Ruling PC3)func SpendGraph(ownerSeat uuid.UUID) workflow.Graphfunc TopUpGraph(ownerSeat uuid.UUID) workflow.Graphfunc CloseoutGraph(ownerSeat uuid.UUID) workflow.Graph// rules.go. Each rule file assigns its slice in its own init()// (Tasks 7, 10, 13), so parallel tasks never edit one line.var spendRules, topUpRules, closeoutRules []accounting.PostingRulefunc Rules() []accounting.PostingRule // slices.Concat(spendRules, topUpRules, closeoutRules)// instance.go — shared by the three document kinds.type TransitionInput struct {ID uuid.UUID // the spend, top-up or close-outFrom, To stringActor uuid.UUIDReason string}func stateOf(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID) (workflow.Instance, workflow.Definition, error)func mayTake(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, actor uuid.UUID, to string) (bool, error)func actsOn(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, member uuid.UUID) (bool, error)func mayReview(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, actor uuid.UUID) (bool, error)func reverses(s posting.Semantic) bool // Rejected || Cancelledpackage pettycashtest// UseRules registers Plan 4's Poster over a fresh RuleSet holding// expenses.Rules() and pettycash.Rules(); restored at Cleanup.func (e *Env) UseRules(t *testing.T) -
Step 1: Write the failing tests —
kinds_test.go:
func TestEveryPettyCashGraphPassesTheValidator(t *testing.T) { for _, k := range []string{pettycash.KindSpend, pettycash.KindTopUp, pettycash.KindCloseout} { workflowtest.RequireSeededGraphValid(t, k) }}func TestTheSpendGraphIsTheContractsShape(t *testing.T) { // g := pettycash.SpendGraph(seat). Kind petty_cash_spend, AccrualFirst. // States exactly (key, semantic, terminal, control): draft/draft/false; // recorded/accrual/false/petty_cash_ap; spend_paid/payout/true; // paid_confirmed/acknowledged/true (Ruling PC4); voided/cancelled/true; // not_reimbursable/rejected/true; disputed/dispute/false. // Edges exactly (from, to, gate, action, requires_reason, reverses) as // Ruling PC5's table. Grants exactly: not_reimbursable <- superior; // disputed <- position seat.}func TestTheTopUpGraphIsTheContractsShape(t *testing.T) // CashOnly; PC5's table; PC6's four grantsfunc TestTheCloseoutGraphIsCashOnly(t *testing.T) // CashOnly; requested/draft, confirmed/payout/terminal, cancelled/cancelled/terminal; edges requested->confirmed (target, "confirm") and requested->cancelled (reporter, "withdraw", reason required, no reversal); grants: confirmed <- seatfunc TestADismissedDisputeCannotReturnToAPayoutState(t *testing.T) { // Why paid_confirmed exists (Ruling PC4). A copy of SpendGraph with the // dismiss edge re-pointed disputed -> spend_paid and paid_confirmed // removed: ValidateGraph(g, "MYR") reports rule 3's "payout_on_cycle" // (validate.go). PRESENCE: the unmodified graph reports nothing.}func TestAFloatTopUpCannotAccrue(t *testing.T) { // A copy of TopUpGraph whose reviewed state is made accrual (control // petty_cash_ap): ValidateGraph reports accrual_in_cash_only (contract // §1.2). PRESENCE first.}func TestProvisioningPublishesTheThreeKinds(t *testing.T) { // pettycashtest.New(t): workflow.Latest(ctx, tx, kind) for each of the // three kinds is version 1 — step 60 seeded them because linking this // package registered them (contract §1.3).}instance_test.go. It uses a no-op poster and a float_topup instance started with workflow.Start(KindTopUp, uuid.New(), Staff, 100) in its own transaction. For a spend, it starts a petty_cash_spend instance and moves it draft → recorded with workflow.Transition.
func TestMayTakeFollowsTheGrants(t *testing.T) // top-up in requested: Owner may take "reviewed"; Staff (reporter) may not; Cashier may notfunc TestActsOnCoversTheQueueAndTheLog(t *testing.T) { // Staff acts on (withdraw is available); Owner acts on (review); // Cashier does not. Owner reviews; after the move Cashier still does not, // Owner does (decision log and approve).}func TestTheReviewerIsWhoeverMayRejectARecordedSpend(t *testing.T) { // Spend instance recorded (reporter Staff): mayReview(Owner) true (the // superior's grant on not_reimbursable); mayReview(Staff) false (self); // mayReview(Cashier) false. In draft: mayReview(Owner) false.}-
Step 2: Run and watch fail.
-
Step 3: Implement
kinds.go. The builders are exactly these. Labels areenandzh, as the seeded expense claim’s are.
func init() { workflow.RegisterSeededGraph(KindSpend, SpendGraph) workflow.RegisterSeededGraph(KindTopUp, TopUpGraph) workflow.RegisterSeededGraph(KindCloseout, CloseoutGraph)}
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 { return workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l}}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}}func why(e workflow.EdgeSpec, reverses bool) workflow.EdgeSpec { e.RequiresReason, e.ReversesAccrual = true, reverses return e}func seat(ownerSeat uuid.UUID, state string) workflow.GrantSpec { return workflow.GrantSpec{StateKey: state, GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}}
// SpendGraph is spec 8.2 as the wave-3 contract §2.2 fixes it, plus the// dismissal target Ruling PC4 adds.func SpendGraph(ownerSeat uuid.UUID) workflow.Graph { recorded := st(2, "recorded", posting.Accrual, false, ls("Recorded", "已记录")) recorded.ControlKind = "petty_cash_ap" // spec 5.5: Petty Cash AP for a spend dispute := ls("Raise dispute", "提出争议") return workflow.Graph{ Kind: KindSpend, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿")), recorded, st(3, "spend_paid", posting.Payout, true, ls("Paid from float", "已从备用金支付")), st(4, "paid_confirmed", posting.Acknowledged, true, ls("Paid (dispute dismissed)", "已支付(争议驳回)")), st(5, "voided", posting.Cancelled, true, ls("Voided", "已作废")), st(6, "not_reimbursable", posting.Rejected, true, ls("Not reimbursable", "不予报销")), st(7, "disputed", posting.Dispute, false, ls("Disputed", "有争议")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "recorded", workflow.GateReporter, "record", ls("Record", "记录")), ed(2, "recorded", "spend_paid", workflow.GateReporter, "pay", ls("Pay from float", "从备用金支付")), why(ed(3, "recorded", "voided", workflow.GateReporter, "void", ls("Void", "作废")), true), why(ed(4, "recorded", "not_reimbursable", workflow.GateTarget, "reject", ls("Not reimbursable", "不予报销")), true), why(ed(5, "spend_paid", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(6, "paid_confirmed", "disputed", workflow.GateReporter, "dispute", dispute), false), why(ed(7, "disputed", "voided", workflow.GateSource, "uphold", ls("Uphold and void", "支持争议并作废")), true), ed(8, "disputed", "paid_confirmed", workflow.GateSource, "dismiss", ls("Dismiss dispute", "驳回争议")), }, Grants: []workflow.GrantSpec{ {StateKey: "not_reimbursable", GranteeKind: workflow.GranteeSuperior}, seat(ownerSeat, "disputed"), }, }.Normalized()}TopUpGraph follows PC5’s top-up table and PC6’s grants in the same style. It uses workflow.CashOnly. The advance edges carry why(…, false), and disputed → rejected carries why(…, true). CloseoutGraph follows PC3. All three are passed through .Normalized().
-
Step 4: Implement
rules.goas in Interfaces. Implementinstance.go:mayTakeis Plan 5’smayTake(internal/platform/expenses/transition.go) copied, with the same unroutable-submission exception.actsOnreturns true if anyAvailableTransitionsentry isAllowed, or if anyDecisionsrow hasActororAuthorityMemberequal to the member.mayReviewloads the instance and definition. It returns true if someAvailableentry whoseTostate has semanticposting.RejectedisAllowedfor the actor, and the actor is notinst.Reporter.export_instance_test.goexportsMayTake,ActsOnandMayReviewfor the tests.
Add
UseRulestopettycashtest. -
Step 5: Run.
go test ./internal/platform/pettycash/... -count=1→ PASS. Then rungo test ./internal/platform/workflow/... ./internal/platform/expenses/... -count=1. Linkingpettycashchanges what step 60 seeds in any test binary that imports it. Expect no change yet, because nothing else importspettycashuntil Task 14. Say so in the report. -
Step 6: Sabotage. In
SpendGraph, setReversesAccrualfalse ondisputed → voided.TestEveryPettyCashGraphPassesTheValidatorgoes red with rule 5’sreversal_missing. Quote it, then restore. -
Step 7: Commit.
git add internal/platform/pettycashgit commit -m "Register the petty-cash spend, top-up and close-out graphs and the rule registry
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 5: Floats — opening, the float account, reading, closing an empty float
Section titled “Task 5: Floats — opening, the float account, reading, closing an empty float”Needs: Tasks 1 (B: OwnerModule), 2, 4.
Files:
- Create:
internal/platform/pettycash/float.go,internal/platform/pettycash/errors.go,internal/platform/pettycash/text.go,internal/platform/pettycash/export_text_test.go - Modify:
internal/platform/pettycash/pettycashtest/env.go(addFloat) - Test:
internal/platform/pettycash/float_test.go,internal/platform/pettycash/text_test.go
Interfaces:
-
Consumes:
chart.GetAccountByCode,chart.CreateAccount,chart.AccountInput{…, OwnerModule *string}(contract §1.5; the CHECKaccount_owner_module_knownadmits'pettycash'),chart.TypeAsset;company.Get(base currency, asexpenses.CreateClaimreads it);numbering.Allocate,numbering.Format;audit.Write;activity.Record;ledger.Post(test: the manual-line refusal). -
Produces:
const (StatusOpen, StatusClosing, StatusClosed = "open", "closing", "closed"FloatHeaderCode = "1530" // contract §2.2OwnerModule = "pettycash" // contract §1.5// Referenced by the binders (Tasks 9, 13); the module registers them.PermissionReadAll = "pettycash.read_all"PermissionManage = "pettycash.float.manage")type Float struct {ID uuid.UUIDCode stringHolderMemberID uuid.UUIDHolderName string // the holder party's legal_nameAccountID uuid.UUIDCurrency stringCashBalance, Committed money.AmountStatus stringRefundProofDocumentID *uuid.UUIDOpenedOn time.TimeClosedOn *time.TimeOpenedBy uuid.UUIDCreatedAt, UpdatedAt time.Time}func (f Float) Available() money.Amount // CashBalance - Committed// FloatRef is exactly what Plan 9 D2 reads (contract §2.2).type FloatRef struct {ID uuid.UUIDCode stringHolderMemberID uuid.UUIDHolderName stringAccountID uuid.UUIDOpenedOn time.TimeClosedOn *time.Time}func (f Float) Ref() FloatReftype OpenInput struct{ HolderMemberID, Actor uuid.UUID }func OpenFloat(ctx context.Context, tx pgx.Tx, c clock.Clock, in OpenInput) (Float, error)func ReadFloat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Float, error)func LockFloat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Float, error) // FOR UPDATE// liveFloatOf is the holder's float that is not closed; ErrNoOpenFloat if none.func liveFloatOf(ctx context.Context, tx pgx.Tx, holder uuid.UUID, lock bool) (Float, error)type FloatFilter struct{ Holder *uuid.UUID }func SearchFloats(ctx context.Context, tx pgx.Tx, f FloatFilter) ([]Float, error) // newest firstfunc ListFloats(ctx context.Context, tx pgx.Tx) ([]FloatRef, error) // Plan 9 D2func GetFloat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (FloatRef, error) // Plan 9 D2func CloseEmptyFloat(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Float, error)func CanReadFloat(f Float, member uuid.UUID, readAll bool) bool // holder, or readAll (read_all or manage)func holderParty(ctx context.Context, tx pgx.Tx, member uuid.UUID) (uuid.UUID, error)// errors.go — shared by every later task.var (ErrFloatNotFound = errors.New("pettycash: float not found")ErrNoFloatHeader = errors.New("pettycash: the chart has no active 1530 petty cash floats header")ErrHolderHasLiveFloat = errors.New("pettycash: this member already holds a float that is not closed")ErrHolderInactive = errors.New("pettycash: a float's holder must be an active member")ErrNoOpenFloat = errors.New("pettycash: this member holds no open float")ErrFloatNotOpen = errors.New("pettycash: the float is closing and takes no new money in or out")ErrFloatClosed = errors.New("pettycash: the float is closed")ErrFloatNotEmpty = errors.New("pettycash: the float still holds cash or commitments; close it out instead")ErrNotHolder = errors.New("pettycash: only the float's holder may do this")ErrNotDraft = errors.New("pettycash: only a draft can be changed")ErrInstanceMismatch = errors.New("pettycash: the event's instance is not this document's instance")ErrAmountDrift = errors.New("pettycash: the document's amount and the workflow amount disagree"))type InsufficientFloatError struct {FloatID uuid.UUIDAvailable, Needed money.Amount}type RevisionMismatchError struct{ Current int }type FieldError struct{ Field, Rule, Detail string } // Error(): "field=<f> rule=<r>: <detail>" (Plan 5 R43)// text.go: §11A.3, the same script test as Plan 5's CheckRecordText (which// pettycash may not import): trims s; blank -> *FieldError{field,// "required"}; longer than max runes -> {field, "too_long"}; any Han,// Kana or Hangul rune -> {field, "language"}. Used by Tasks 7 and 9.func checkRecordText(field, s string, max int) (string, error) -
Step 1: Write the failing tests —
float_test.go:
func TestOpeningAFloatMakesItsOwnCashAccount(t *testing.T) { // OpenFloat{Holder: Staff, Actor: Owner}: Code "PF-2026-0001", status // open, 0/0, Currency "MYR", OpenedOn Today, HolderName = Staff's party // legal_name. chart.GetAccount(AccountID): ParentID = the 1530 header, // Code "1530.PF-2026-0001", Type asset, IsPostable, Currency "MYR", // OwnerModule "pettycash". One audit row (entity 'petty_cash_float', // action 'open', actor Owner) and one activity row (verb 'opened').}func TestAManualEntryCannotTouchAFloatAccount(t *testing.T) { // Contract §1.5. PRESENCE: ledger.Post manual Dr 1510 / Cr 1520 1.00 // posts. ABSENCE: the same with Dr = the float's account -> // *ledger.OwnedAccountManualError{Code: "1530.PF-2026-0001", Module: // "pettycash"}; no entry.}func TestAFloatNeedsTheFloatHeader(t *testing.T) { // PRESENCE: OpenFloat(Staff) works. chart.UpdateAccount the 1530 header's // code to "1539": OpenFloat(Cashier) -> ErrNoFloatHeader, no account made.}func TestAHolderHasOneLiveFloat(t *testing.T) // second OpenFloat(Staff) -> ErrHolderHasLiveFloat; after CloseEmptyFloat, a third opens as PF-2026-0002func TestAFloatHolderMustBeAnActiveMember(t *testing.T) // admin: organization_member.active = false -> ErrHolderInactive; PRESENCE first with an active memberfunc TestFloatRefsAreWhatPlan9Reads(t *testing.T) { // ListFloats returns both floats' refs, newest first, with the fields of // FloatRef; GetFloat(id) equals the listed ref; another org (e.Another): // GetFloat(id) -> ErrFloatNotFound and ListFloats is empty.}func TestAnEmptyFloatClosesDirectly(t *testing.T) { // CloseEmptyFloat: status closed, ClosedOn Today; audit 'close'; again -> // ErrFloatClosed. A float with committed > 0 (admin UPDATE committed after // an admin movement) -> ErrFloatNotEmpty (PRESENCE: the empty one closed).}func TestTheFloatLockSerializes(t *testing.T) { // Tx A: LockFloat and hold (10 s context). Tx B: LockFloat blocks // (a 300 ms probe context times out); release A; B completes.}func TestAFloatIsReadByItsHolderOrFinance(t *testing.T) // CanReadFloat: holder true; Cashier false; Cashier with readAll truetext_test.go (through export_text_test.go’s CheckRecordText): TestRecordTextAcceptsEnglishAndMalayAndRefusesCJK. “Kertas A4 untuk pejabat” is accepted and trimmed. “午餐”, “ランチ” and “점심” are each refused with rule=language. “ “ is refused with rule=required. 501 runes against a maximum of 500 is refused with rule=too_long.
-
Step 2: Run and watch fail.
-
Step 3: Implement
OpenFloat, in this order:- Read the org.
- Read the holder:
SELECT m.active, p.legal_name FROM organization_member m JOIN party p ON p.org_id = m.org_id AND p.id = m.party_id WHERE m.org_id = $1 AND m.id = $2. No row, oractive = false, givesErrHolderInactive. - If
liveFloatOf(holder, false)finds one, returnErrHolderHasLiveFloat. - Call
chart.GetAccountByCode(FloatHeaderCode).chart.ErrNotFound,IsPostable,!IsActiveorType != assetall giveErrNoFloatHeader. - Read the base currency from
company.Get. - Allocate the code:
numbering.Allocate(ctx, tx, "petty_cash_float", year), thencode := numbering.Format("PF", year, n). - Create the account:
chart.CreateAccount(ctx, tx, c, chart.AccountInput{ParentID: &header.ID, Code: FloatHeaderCode + "." + code, Name: "Petty cash float " + code, Type: chart.TypeAsset, Currency: &base, OwnerModule: &owner}). INSERT petty_cash_float. Map 23505 onpetty_cash_float_one_live_per_holdertoErrHolderHasLiveFloat(the race).- Write
audit.Writeandactivity.Record.
Every read joins
partyforHolderName.CloseEmptyFloat:LockFloat. A closing float givesErrFloatNotOpen; a closed one givesErrFloatClosed. A float with a non-zero balance givesErrFloatNotEmpty. Then runUPDATE … SET status='closed', closed_on=$today, updated_at=$now, and write auditcloseand activityclosed.holderParty:SELECT party_id FROM organization_member WHERE org_id=$1 AND id=$2.Add
pettycashtest.Float(t, holder) pettycash.Float(opened byOwner). -
Step 4: Run. PASS.
-
Step 5: Sabotage. Drop
OwnerModulefrom theAccountInput.TestAManualEntryCannotTouchAFloatAccountgoes red (the manual entry posts). Quote it, then restore. -
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Open petty-cash floats as module-owned cash accounts under the 1530 header
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 6: The movement writer
Section titled “Task 6: The movement writer”Needs: Task 5.
Files:
- Create:
internal/platform/pettycash/movement.go,internal/platform/pettycash/export_movement_test.go - Test:
internal/platform/pettycash/movement_test.go
Interfaces:
-
Consumes:
LockFloat,ReadFloat,InsufficientFloatError,ErrFloatClosed(Task 5);ledger.Unreversed,ledger.Get,ledger.SemanticPayout,ledger.Post,ledger.Reverse(tests);audit.Write. -
Produces:
const (MovementTopUp = "topup"MovementTopUpReversal = "topup_reversal"MovementSpendPayout = "spend_payout"MovementSpendPayoutReversal = "spend_payout_reversal"MovementClaimSettlement = "claim_settlement"MovementClaimSettlementReversal = "claim_settlement_reversal"MovementCloseout = "closeout"SourceClaim = "expense_claim" // source_type for a claim settled from the tin)type Movement struct {ID, FloatID uuid.UUIDOccurredAt time.TimeAmount money.Amount // signed: + into the tin, - out of itKind, SourceType stringSourceID uuid.UUIDJournalEntryID uuid.UUIDReversesMovementID *uuid.UUIDRecordedBy uuid.UUID}type movementInput struct {Float Float // as locked by the caller, and as it stands NOW (re-read after any committed change)Kind stringSourceType stringSourceID uuid.UUIDJournalEntryID uuid.UUIDAmount money.AmountReverses *uuid.UUIDActor uuid.UUID}// writeMovement inserts one movement (the trigger moves cash_balance) and// audits the float. The caller holds the float lock. Refuses a closed// float (ErrFloatClosed) and a debit beyond available (*InsufficientFloatError),// both before the INSERT and, for a race, from the constraint names.func writeMovement(ctx context.Context, tx pgx.Tx, c clock.Clock, in movementInput) (Movement, Float, error)// postedEntry is the instance's unreversed entry of one semantic, with lines.var ErrNoPostedEntry = errors.New("pettycash: the instance has no unreversed entry of that semantic")func postedEntry(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID, semantic string) (ledger.Entry, error)// netOn is SUM(debit - credit) of e's lines on one account.func netOn(e ledger.Entry, accountID uuid.UUID) money.Amount// forwardMovement is the source's newest movement of kind that no movement// reverses; nil if none.func forwardMovement(ctx context.Context, tx pgx.Tx, sourceType string, sourceID uuid.UUID, kind string) (*Movement, error)func ListMovements(ctx context.Context, tx pgx.Tx, floatID uuid.UUID) ([]Movement, error) // newest first -
Step 1: Write the failing tests —
movement_test.go.export_movement_test.goexportsWriteMovement,MovementInput(an alias of the unexported struct),PostedEntry,NetOnandForwardMovement.- Journal entries for the FK are posted directly with
ledger.Post, usingSemantic: "payout",SourceKind: KindTopUp, aSourceID, and theInstanceIDof afloat_topupinstance started in an earlier transaction. - Their lines are Dr the float’s account and Cr 1520.
ledger.Postallows a non-manual line on an owned account.
- Journal entries for the FK are posted directly with
func TestAMovementMovesTheBalanceAndIsAudited(t *testing.T) { // LockFloat; WriteMovement{topup, +100.0000, entry E1}: returned Float // has CashBalance 100.0000; the row names E1, kind topup, source // float_topup; one audit row entity 'petty_cash_float' action 'movement' // whose After names the movement id, kind, amount and cash_balance // "100.0000".}func TestAMovementBeyondAvailableIsRefusedTyped(t *testing.T) { // Funded +100, then admin UPDATE committed = 30. PRESENCE: -70 succeeds // (available 0 after). Then on a second float funded +100/committed 30: // -80 -> *InsufficientFloatError{Available 70.0000, Needed 80.0000}; no row.}func TestAClosedFloatTakesNoMovement(t *testing.T) // CloseEmptyFloat, then WriteMovement(+10) -> ErrFloatClosedfunc TestPostedEntryFindsTheUnreversedLeg(t *testing.T) { // E1 posted for the instance: PostedEntry(inst, "payout") = E1 with its // lines; NetOn(E1, float account) = +100.0000. ledger.Reverse(E1): // PostedEntry -> ErrNoPostedEntry; ledger.Get(E1).ReversedByID is set.}func TestForwardMovementSkipsReversedOnes(t *testing.T) { // topup M1 (+100, E1); ForwardMovement(float_topup, src, topup) = M1. // topup_reversal M2 (-100, E1's reversal, Reverses M1): ForwardMovement -> nil.}func TestMovementsListNewestFirst(t *testing.T)-
Step 2: Run and watch fail.
-
Step 3: Implement
writeMovement:in.Float.Status == StatusClosedgivesErrFloatClosed.- If
in.Amount.IsNegative()andin.Float.Available().Add(in.Amount).IsNegative(), return&InsufficientFloatError{FloatID, Available: in.Float.Available(), Needed: in.Amount.Neg()}. INSERT … RETURNINGwithoccurred_at = c.Now().UTC(). A 23514 namingpetty_cash_float_available_nonnegativemaps to*InsufficientFloatError; one namingpetty_cash_float_closed_shapemaps toErrFloatClosed.f := ReadFloat(in.Float.ID).- Write
audit.Write{EntityKind: "petty_cash_float", EntityID: f.ID, Action: "movement", ActorID: &in.Actor, After: {movement_id, kind, amount, source_type, source_id, journal_entry_id, cash_balance}}.
postedEntry:ledger.Unreversed(instanceID), then the first header withSemantic == semantic, elseErrNoPostedEntry, thenledger.Get(header.ID).forwardMovement:… WHERE m.org_id = $1 AND m.source_type = $2 AND m.source_id = $3 AND m.kind = $4 AND NOT EXISTS (SELECT 1 FROM float_movement r WHERE r.org_id = $1 AND r.reverses_movement_id = m.id) ORDER BY m.occurred_at DESC, m.id DESC LIMIT 1. -
Step 4: Run. PASS.
-
Step 5: Sabotage. Delete the Go pre-check in step 2 of
writeMovement.TestAMovementBeyondAvailableIsRefusedTypedstays green, because the constraint mapping catches it. Then delete that mapping too, and the test goes red with a raw 23514. Quote both results, then restore. -
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Write float movements that mirror their journal entries and move the stored balance
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 7: Top-ups — requests, edits, visibility, and the (float_topup, payout) rule
Section titled “Task 7: Top-ups — requests, edits, visibility, and the (float_topup, payout) rule”Needs: Tasks 3, 4, 5. It runs beside Tasks 6 and 9.
Files:
- Create:
internal/platform/pettycash/topup.go,internal/platform/pettycash/topup_posting.go - Test:
internal/platform/pettycash/topup_test.go,internal/platform/pettycash/topup_posting_test.go
Interfaces:
-
Consumes: Tasks 4–5 (
KindTopUp,stateOf,actsOn,ReadFloat,holderParty,checkRecordText, errors);workflow.Start,workflow.SetAmount,workflow.Get;numbering;chart.DefaultJournal,chart.JournalBank;accounting.PostingRule,accounting.Source,accounting.Draft;ledger.Line. -
Produces:
const (PurposeTopUp = "topup"; PurposeReplenishment = "replenishment")type TopUp struct {ID uuid.UUIDReference stringFloatID, RequestedBy, InstanceID uuid.UUIDPurpose stringAmount money.AmountCurrency, Reason stringRevision intState stringSemantic posting.SemanticCreatedAt, UpdatedAt time.Time}type TopUpInput struct {FloatID, Actor uuid.UUIDAmount money.AmountReason string}func CreateTopUp(ctx context.Context, tx pgx.Tx, c clock.Clock, in TopUpInput) (TopUp, error)type TopUpPatch struct {TopUpID, Actor uuid.UUIDRevision intAmount *money.AmountReason *string}func UpdateTopUp(ctx context.Context, tx pgx.Tx, c clock.Clock, p TopUpPatch) (TopUp, error)func GetTopUp(ctx context.Context, tx pgx.Tx, id uuid.UUID) (TopUp, error) // Plan 9 reads Purpose (PC28)func LockTopUp(ctx context.Context, tx pgx.Tx, id uuid.UUID) (TopUp, error)type TopUpFilter struct{ FloatID *uuid.UUID }func ListTopUps(ctx context.Context, tx pgx.Tx, f TopUpFilter) ([]TopUp, error)func CanReadTopUp(ctx context.Context, tx pgx.Tx, c clock.Clock, tu TopUp, member uuid.UUID, readAll bool) (bool, error)var ErrTopUpNotFound, ErrReplenishmentFixed, ErrNoBankAccount errortype TopUpPayoutRule struct{} // Kind() KindTopUp; Semantic() posting.Payoutfunc (TopUpPayoutRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error)// topup_posting.go: func init() { topUpRules = []accounting.PostingRule{TopUpPayoutRule{}} } -
Step 1: Write the failing tests —
topup_test.go. It uses a no-op poster for the moves; the real Poster is used intopup_posting_test.go.
func TestAHolderRequestsATopUp(t *testing.T) { // Staff's float; CreateTopUp{Staff, 200.00, "Weekly float"}: Reference // "PT-2026-0001", Purpose topup, State "requested", Semantic draft, // Revision 1; the instance's reporter is Staff and its amount 200.0000; // one audit row ('float_topup', 'create').}func TestOnlyTheHolderRequestsATopUp(t *testing.T) { // PRESENCE above. Owner -> ErrNotHolder. Admin sets the float closing // (with a refund proof document): Staff -> ErrFloatNotOpen. Amount 0 -> // *FieldError{amount, positive}.}func TestATopUpIsEditedOnlyAsARequest(t *testing.T) { // UpdateTopUp{Revision 1, Amount 250}: Revision 2, instance amount // 250.0000 (workflow.SetAmount); Revision 1 again -> *RevisionMismatchError // {Current: 2}. Owner moves requested -> reviewed (workflow.Transition, // separate tx): UpdateTopUp -> ErrNotDraft.}func TestATopUpReasonIsARecord(t *testing.T) // "补充现金" -> *FieldError{reason, language}; " " -> {reason, required}func TestATopUpIsReadByItsPeople(t *testing.T) // CanReadTopUp: Staff true; Owner true (may review); Cashier false; Cashier readAll truetopup_posting_test.go. It calls e.UseRules(t), walks the top-up with workflow.Transition as Owner (the movement is Task 8’s), and pays with approved → paid.
func TestPayingATopUpDebitsTheFloatAgainstTheBank(t *testing.T) { // One payout entry: source_kind "float_topup", source_id = top-up id, // instance_id = its instance, journal BNK, dated Today. Lines: Dr the // float's account 200.0000 party Staff's party; Cr 1520 200.0000 party // Staff's party. Nothing posted at review or approval.}func TestATopUpRuleRefusesAnotherInstance(t *testing.T) { // Build directly with a Source naming the top-up but another instance id // -> ErrInstanceMismatch. PRESENCE: Build with the right Source returns // a two-line Draft.}func TestEveryTopUpPostingIsTraceable(t *testing.T) { // Spec 11A.7 / wave-3 §5 "the top-up's approval": from the entry, // source_id -> GetTopUp -> its instance; workflow.Decisions names Owner // as the actor of requested->reviewed, reviewed->approved and // approved->paid, each with a persisted eligible set.}func TestAnUpheldTopUpDisputeIsMirroredPastPayout(t *testing.T) { // Contract §1.1 for a cash_only kind: paid -> disputed (Staff, reason) -> // rejected (Owner, reason): one reversal entry mirroring the payout; the // float account and 1520 net to zero for the source.}-
Step 2: Run and watch fail.
-
Step 3: Implement
CreateTopUp:f := ReadFloat.in.Actor != f.HolderMemberIDgivesErrNotHolder. A closing float givesErrFloatNotOpen; a closed one givesErrFloatClosed.- Validate: amount must be positive (
*FieldError{amount, positive}), and the reason goes throughcheckRecordText("reason", s, 500). - Allocate:
numbering.Allocate(ctx, tx, "float_topup", year), prefixPT. id := uuid.New(), thenworkflow.Start(KindTopUp, id, f.HolderMemberID, amount).INSERTwith purposetopupandcurrency = f.Currency.- Write
audit.Writecreate.
UpdateTopUp:LockTopUp, then check the actor is the holder and the revision matches.- A replenishment gives
ErrReplenishmentFixed. A non-draft semantic givesErrNotDraft. - Validate. If the amount changed, call
workflow.SetAmount. UPDATE … revision = revision + 1, then write auditupdate.
Reads join
workflow_instanceandworkflow_stateforStateandSemantic, as Plan 5 R40 does.CanReadTopUp:tu.RequestedBy == member || readAll || actsOn(…).TopUpPayoutRule.Build:tu := GetTopUp(src.Event.SourceID). A mismatched instance givesErrInstanceMismatch.inst := workflow.Get.tu.Amount.Cmp(inst.Amount) != 0givesErrAmountDrift.f := ReadFloat(tu.FloatID).f.Status != StatusOpengivesErrFloatNotOpen, a defence behind Task 8’s pre-check.j := chart.DefaultJournal(ctx, tx, chart.JournalBank).j.DefaultAccountID == nilgivesErrNoBankAccount.party := holderParty.- Return
Draft{JournalID: &j.ID, Currency: src.BaseCurrency, Description: "Petty cash top-up " + tu.Reference + " to float " + f.Code, Lines: [{AccountID: f.AccountID, Debit: tu.Amount, PartyID: &party}, {AccountID: *j.DefaultAccountID, Credit: tu.Amount, PartyID: &party}]}.
-
Step 4: Run. PASS.
-
Step 5: Sabotage. Make the rule credit the float’s account and debit the bank.
TestPayingATopUpDebitsTheFloatAgainstTheBankgoes red. Quote it, then restore. -
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Request petty-cash top-ups and post their payout to the float's own account
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 8: Top-up transitions — cash into the tin, and back out on an upheld dispute
Section titled “Task 8: Top-up transitions — cash into the tin, and back out on an upheld dispute”Needs: Tasks 6, 7.
Files:
- Create:
internal/platform/pettycash/topup_transition.go - Modify:
internal/platform/pettycash/pettycashtest/env.go(addFund) - Test:
internal/platform/pettycash/topup_transition_test.go
Interfaces:
-
Consumes:
LockTopUp,GetTopUp(7);LockFloat, errors (5);writeMovement,postedEntry,forwardMovement(6);stateOf,mayTake,reverses,TransitionInput(4);workflow.Transition,workflow.Decision,workflow.ErrStaleState;ledger.Get;audit.Write;activity.Record. -
Produces:
// TransitionTopUp moves one top-up along one edge (Ruling PC19). Order:// top-up FOR UPDATE -> float FOR UPDATE (entering payout, rejected or// cancelled) -> checks -> workflow.Transition -> movement -> audit.// The caller's transaction must not commit on error; handlers map its// error through ledger.MapError.func TransitionTopUp(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (TopUp, workflow.Decision, error)package pettycashtest// Fund walks a new top-up of amount on f to paid: the holder requests,// Owner reviews, approves and pays, each in its own transaction. Needs// e.UseRules(t) first, and f's holder must report to Owner.func (e *Env) Fund(t *testing.T, f pettycash.Float, amount string) pettycash.TopUp -
Step 1: Write the failing tests —
topup_transition_test.go. It callse.UseRules(t); the float ise.Float(t, e.Staff).
func TestPayingATopUpPutsCashInTheTin(t *testing.T) { // Staff requests 200.00; Owner: requested->reviewed, ->approved, ->paid, // each through TransitionTopUp in its own tx. The float: CashBalance // 200.0000, Committed 0. ListMovements: one topup +200.0000 whose // JournalEntryID is the top-up's payout entry and SourceID the top-up. // The last audit row ('float_topup', 'transition') names the decision // and the movement; the activity verb is 'topped_up'.}func TestATopUpIsNotPaidIntoAFloatThatIsNotOpen(t *testing.T) { // PRESENCE: a first top-up pays. A second, approved; admin sets the float // closing (refund proof): ->paid -> ErrFloatNotOpen; still approved; no // second payout entry; one movement only.}func TestAnUpheldTopUpDisputeTakesTheCashBack(t *testing.T) { // Paid 200. Staff: paid->disputed (reason). Owner: disputed->rejected // (reason): a topup_reversal -200.0000 movement whose ReversesMovementID // is the topup movement and whose JournalEntryID is ledger.Get(payout // entry).ReversedByID; CashBalance 0.}func TestATopUpIsNotReversedOnceItsCashIsCommitted(t *testing.T) { // Paid 200; admin UPDATE committed = 150 (allowed: committed is Go's). // Uphold -> *InsufficientFloatError{Available 50.0000, Needed 200.0000}; // still disputed; no reversal entry (the Poster never ran).}func TestADismissedTopUpDisputeMovesNothing(t *testing.T) // disputed->paid_confirmed: no movement, no entry, balance unchangedfunc TestRejectingBeforePaymentMovesNothing(t *testing.T) // reviewed->rejected (Owner, reason): no movement, no entry, float row untouched (updated_at unchanged)func TestTheTopUpLocksBeforeTheFloatAndTheFloatBeforeTheInstance(t *testing.T) { // Wave-3 §5. Tx A: LockFloat and hold. Goroutine B: TransitionTopUp // (approved -> paid) — blocks. While B waits, probe tx C (300 ms // context): SELECT … FROM float_topup … FOR UPDATE NOWAIT -> 55P03 (B // holds the top-up); SELECT … FROM workflow_instance … FOR UPDATE NOWAIT // succeeds (B has not reached the instance); C rolls back. Release A; B // succeeds. Every wait bounded by a 10 s context.}func TestAStaleFromIsRefusedByTheEngine(t *testing.T) // From "requested" on a reviewed top-up -> workflow.ErrStaleState-
Step 2: Run and watch fail.
-
Step 3: Implement
TransitionTopUp:tu := LockTopUp(in.ID).inst, def := stateOf(tu.InstanceID). Thenfrom, _ := def.State(in.From)andto, known := def.State(in.To).examine := known && inst.StateKey == in.From && mayTake(…, in.Actor, in.To).needFloat := known && (to.Semantic == posting.Payout || reverses(to.Semantic)). If it holds,f := LockFloat(tu.FloatID).- If
examineand the move enterspayout:f.Statusmust beopen(ErrFloatNotOpen, orErrFloatClosed). - If
examineandreverses(to.Semantic):fwd := forwardMovement(KindTopUp, tu.ID, MovementTopUp).- If
fwd != nil:f.Status == closedgivesErrFloatClosed, andf.Available().Cmp(tu.Amount) < 0gives*InsufficientFloatError.
d := workflow.Transition(…).- The Plan 5 R21 guard: if
!examine && known && (to.Semantic == posting.Payout || reverses(to.Semantic)), returnworkflow.ErrStaleState. The engine allowed a move whose checks were skipped, so it rolls back. - Entering
payout:e := postedEntry(tu.InstanceID, ledger.SemanticPayout), thenwriteMovement{Float: f, Kind: MovementTopUp, SourceType: KindTopUp, SourceID: tu.ID, JournalEntryID: e.ID, Amount: tu.Amount, Actor: in.Actor}. - If
reverses(to.Semantic) && fwd != nil:orig := ledger.Get(fwd.JournalEntryID).- Only if
orig.ReversedByID != nil, writewriteMovement{Kind: MovementTopUpReversal, JournalEntryID: *orig.ReversedByID, Amount: tu.Amount.Neg(), Reverses: &fwd.ID}(Ruling PC14).
- Write
audit.Write{EntityKind: "float_topup", Action: "transition", Before: {state}, After: {state, decision_id, movement_id}}, thenactivity.Record(topped_upwhen entering payout, elsetransitioned). - Return
GetTopUp.
Add
Fundtopettycashtest. -
Step 4: Run. PASS.
-
Step 5: Sabotage. Move step 4’s
LockFloatafterworkflow.Transition.TestTheTopUpLocksBeforeTheFloatAndTheFloatBeforeTheInstancegoes red, because the instance probe finds the instance locked. Quote it, then restore. -
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Move top-ups through their workflow: cash into the tin on payment, back out on an upheld dispute
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 9: Spends — drafts, the receipt, the reviewer’s outcome, the spend binder
Section titled “Task 9: Spends — drafts, the receipt, the reviewer’s outcome, the spend binder”Needs: Tasks 1 (B: the binder registry), 3, 4, 5. It runs beside Tasks 6 and 7.
Files:
- Create:
internal/platform/pettycash/spend.go,internal/platform/pettycash/spend_review.go,internal/platform/pettycash/binder_spend.go - Modify:
internal/platform/pettycash/pettycashtest/env.go(addReceipt,PendingReceipt,DraftSpend,Grant) - Test:
internal/platform/pettycash/spend_test.go,internal/platform/pettycash/spend_review_test.go,internal/platform/pettycash/binder_spend_test.go
Interfaces:
-
Consumes: Tasks 4–5;
internal/platform/taxrecord(the base’s extraction fromexpenses, names per Task 1);documents.Lock,documents.Get,documents.StatusVerified,documents.RegisterBinder,documents.Binder,documents.Binding,documents.BoundElsewhere,documents.MayRead(ctx, tx, c, docID, memberID)(contract §1.4/§7 as built);chart.GetAccount, the tax-code reader inchart/taxcode.go(read itsGet…function and validity fields as built);member.Can;workflow.Start,workflow.SetAmount; tests:expenses.CreateClaim,expenses.AddLine,expenses.AttachDocument,expenses.ErrDocumentBoundElsewhere,expenses.DocumentBoundElsewhereError. -
Produces:
type Outcome string // Ruling PC7const (OutcomeQueried Outcome = "queried"OutcomeVerified Outcome = "verified" // the only value that opens the pay edgeOutcomeNotReimbursable Outcome = "not_reimbursable")var Outcomes = []Outcome{OutcomeQueried, OutcomeVerified, OutcomeNotReimbursable} // the closed set// TaxRecord is the spend's §6.7 record (Ruling PC16). Validation is// taxrecord's; the field names below map one-to-one onto taxrecord's// record type as Task 1 reconciles it.type TaxRecord struct {SupplierName, SupplierTIN, EInvoiceUUID, DocumentBasis, RelaxationReason *stringEInvoiceValidatedAt *time.Time}type Spend struct {ID uuid.UUIDReference stringFloatID, HolderMemberID, InstanceID uuid.UUIDExpenseDate time.TimeDescription stringTax TaxRecordAmount money.AmountCurrency stringExpenseAccountID uuid.UUIDTaxCodeID, ReceiptDocumentID *uuid.UUIDOutcome *Outcome // nil: not yet reviewedReviewedBy *uuid.UUIDReviewedAt *time.TimeRevision intState stringSemantic posting.SemanticCreatedAt, UpdatedAt time.Time}func (s Spend) Verified() bool // s.Outcome != nil && *s.Outcome == OutcomeVerifiedtype SpendInput struct {FloatID, Actor uuid.UUIDExpenseDate time.TimeDescription stringTax TaxRecordAmount money.AmountExpenseAccountID uuid.UUIDTaxCodeID *uuid.UUID}func CreateSpend(ctx context.Context, tx pgx.Tx, c clock.Clock, in SpendInput) (Spend, error)type SpendPatch struct {SpendID, Actor uuid.UUIDRevision intExpenseDate *time.TimeDescription *stringTax *TaxRecord // replaces the whole record when setAmount *money.AmountExpenseAccountID *uuid.UUIDTaxCodeID **uuid.UUID}func UpdateSpend(ctx context.Context, tx pgx.Tx, c clock.Clock, p SpendPatch) (Spend, error)// BindReceipt: spend FOR UPDATE -> documents.Lock -> BoundElsewhere (contract §7).func BindReceipt(ctx context.Context, tx pgx.Tx, c clock.Clock, spendID, documentID, actor uuid.UUID, revision int) (Spend, error)func GetSpend(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Spend, error)func LockSpend(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Spend, error)type SpendFilter struct{ FloatID *uuid.UUID }func ListSpends(ctx context.Context, tx pgx.Tx, f SpendFilter) ([]Spend, error)func CanReadSpend(ctx context.Context, tx pgx.Tx, c clock.Clock, s Spend, member uuid.UUID, readAll bool) (bool, error)type ReviewInput struct {SpendID, Actor uuid.UUIDRevision intOutcome Outcome}func ReviewSpend(ctx context.Context, tx pgx.Tx, c clock.Clock, in ReviewInput) (Spend, error)func usableExpenseAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) error // *FieldError{expense_account_id, …}// checkTaxRecord normalizes (upper-cases TIN and UUID) and validates the// record's shape through taxrecord at save; complete=true adds the// record-step completeness (a basis is required). einvoice_employee ->// *FieldError{document_basis, employee_named_not_allowed}. taxrecord's// problems come back as *FieldError with taxrecord's own rule codes.func checkTaxRecord(r TaxRecord, complete bool) (TaxRecord, error)var (ErrSpendNotFound, ErrNotReviewable, ErrSelfReview, ErrNotReviewer, ErrOutcomeUnknown,ErrReceiptNotYours, ErrReceiptNotVerified error)// BoundElsewhereError names every record the document already backs.type BoundElsewhereError struct{ Bindings []documents.Binding }package pettycashtestfunc (e *Env) Receipt(t *testing.T, holder uuid.UUID) uuid.UUID // a verified PNG uploaded by holder (e.Document)func (e *Env) PendingReceipt(t *testing.T, holder uuid.UUID) uuid.UUID // documents.Create only: status pendingfunc (e *Env) DraftSpend(t *testing.T, f pettycash.Float, amount string) pettycash.Spend // 6110, "Printer paper", Today, relaxation_receipt with a reason, receipt boundfunc (e *Env) Grant(t *testing.T, member uuid.UUID, keys ...string) // role + role_permission + member_role, via e.H.Admin -
Step 1: Write the failing tests. They use a no-op poster. Where a spend must be
recordedorvoidedbefore Task 11, they move its instance withworkflow.Transitiondirectly in its own transaction.
spend_test.go:
func TestAHolderRecordsASpendDraft(t *testing.T) { // Staff's float. CreateSpend{Staff, Today, "Printer paper", Tax{Supplier // "Kedai Buku Rahmat", basis relaxation_receipt, reason "Small shop, no // e-Invoice"}, 45.60, 6110}: Reference "PS-2026-0001", State "draft", Semantic // draft, Outcome nil, Currency "MYR"; the instance's amount 45.6000 and // reporter Staff; audit ('petty_cash_spend', 'create').}func TestOnlyTheHolderSpendsFromAnOpenFloat(t *testing.T) // Owner -> ErrNotHolder; float set closing (admin) -> ErrFloatNotOpenfunc TestASpendNamesAPostableExpenseAccount(t *testing.T) { // PRESENCE 6110 ok. 1520 -> *FieldError{expense_account_id, not_expense}; // the 1530 header -> {expense_account_id, not_postable}; an inactive // expense account -> {expense_account_id, inactive}.}func TestSpendTextIsARecord(t *testing.T) // description "午餐" -> {description, language}; amount 0 -> {amount, positive}func TestASpendCarriesTheTaxRecord(t *testing.T) { // Ruling PC16. PRESENCE: einvoice_company with TIN "c20880050010" and // uuid "f9d425p6ds7d8iu0000000000a" saves, upper-cased. Then, one each, // with taxrecord's rule codes: einvoice_company without a uuid -> // {einvoice_uuid, …}; relaxation_receipt without a reason -> // {relaxation_reason, …}; TIN "12345" -> {supplier_tin, …}; // einvoice_employee -> {document_basis, employee_named_not_allowed}. A // draft with no basis saves (the basis is required at record, Task 11).}func TestADraftSpendIsEditedWithItsRevision(t *testing.T) { // UpdateSpend{Revision 1, Amount 50.00}: Revision 2, instance amount // 50.0000; Revision 1 again -> *RevisionMismatchError{2}; after // draft -> recorded: UpdateSpend -> ErrNotDraft.}func TestAReceiptIsTheHoldersVerifiedDocument(t *testing.T) { // PRESENCE: Staff's verified receipt binds (Revision 2). Owner's verified // document -> ErrReceiptNotYours; Staff's pending document -> // ErrReceiptNotVerified.}func TestAReceiptBacksOneRecordAcrossConsumers(t *testing.T) { // Contract §1.4. (a) Staff's receipt attached to a line of Staff's draft // claim (expenses.AttachDocument): BindReceipt -> *BoundElsewhereError // whose one Binding is {expense_claim, claim id, "EC-2026-0001"}. // (b) A receipt bound to spend A: BindReceipt on spend B -> // *BoundElsewhereError naming {petty_cash_spend, A, "PS-2026-0001"}; // rebinding it to A itself succeeds (except = A). // (c) From Plan 5's side: expenses.AttachDocument of A's receipt -> // errors.Is(err, expenses.ErrDocumentBoundElsewhere) and the // *expenses.DocumentBoundElsewhereError names petty_cash_spend and PS-2026-0001.}func TestAVoidedSpendsReceiptIsFreeAgain(t *testing.T) { // Ruling PC31. Spend A recorded then voided (workflow.Transition, nop // poster): BindReceipt of A's receipt to a new spend B succeeds. A // not_reimbursable spend C keeps its receipt: binding C's receipt to D -> // *BoundElsewhereError.}func TestASpendIsReadByItsPeople(t *testing.T) { // CanReadSpend: Staff true; Owner false in draft, true once recorded (may // review); Cashier false; Cashier with readAll true.}spend_review_test.go:
func TestTheSuperiorReviewsARecordedSpend(t *testing.T) { // Recorded spend (Staff). ReviewSpend{Owner, Rev, OutcomeVerified}: // Outcome "verified", ReviewedBy Owner, Revision +1, Verified() true; // audit ('petty_cash_spend', 'review', Before outcome null, After // "verified"); activity 'reviewed'. Revise to OutcomeQueried: Outcome // "queried", Verified() false.}func TestNobodyReviewsTheirOwnSpend(t *testing.T) // Staff -> ErrSelfReview (PRESENCE: Owner ok)func TestOnlyTheGrantedReviewerReviews(t *testing.T) // Cashier -> ErrNotReviewer; after Owner delegates petty_cash_spend to Cashier (workflow.CreateDelegation, Kind "petty_cash_spend") -> okfunc TestADraftOrPaidSpendIsNotReviewable(t *testing.T) // draft -> ErrNotReviewable; Outcome("approved") -> ErrOutcomeUnknown; paid (workflow.Transition, nop poster) -> ErrNotReviewablefunc TestOnlyTheExactStringVerifiedIsVerified(t *testing.T) { // Spec 8.2's reason for zero, met by exact comparison (Ruling PC7): // Verified() is false for nil, "queried", "not_reimbursable" and — set // through the struct, bypassing the CHECK — "Verified" and " verified"; // true only for "verified".}func TestReviewerOutcomesMatchTheCheck(t *testing.T) { // The Go set pettycash.Outcomes equals the values parsed from the // petty_cash_spend_outcome_known CHECK (pg_get_constraintdef via admin), // as Plan 6's TestSettlementBehavioursMatchTheCheck does.}binder_spend_test.go:
func TestTheReceiptIsReadableByWhoeverDecidesTheSpend(t *testing.T) { // Staff's receipt bound to a draft spend. documents.MayRead(ctx, tx, c, // doc, Staff) true (uploader); Owner false in draft; after draft -> // recorded, Owner true (the reviewer); Cashier false; after // e.Grant(t, Cashier, "pettycash.read_all") Cashier true (Ruling PC30).}func TestTheSpendBinderIsRegistered(t *testing.T) { // documents.BoundElsewhere(ctx, tx, doc, documents.Binding{}) lists the // spend as {petty_cash_spend, id, reference}. PRESENCE for the absence // in TestAVoidedSpendsReceiptIsFreeAgain.}-
Step 2: Run and watch fail.
-
Step 3: Implement
CreateSpend:f := ReadFloat(in.FloatID).in.Actor != f.HolderMemberIDgivesErrNotHolder. A non-openfloat givesErrFloatNotOpenorErrFloatClosed.- Validate: amount positive;
checkRecordTextfor the description (500), the supplier (200) and the relaxation reason (500), each if given;checkTaxRecord(in.Tax, false);usableExpenseAccount; the tax code, if given, active and valid onExpenseDate. - Allocate:
numbering.Allocate(ctx, tx, "petty_cash_spend", year), prefixPS. id := uuid.New(), thenworkflow.Start(KindSpend, id, f.HolderMemberID, amount).INSERTwithcurrency = f.Currency, then auditcreate.
usableExpenseAccount:chart.GetAccount.Type != expensegivesnot_expense,!IsPostablegivesnot_postable, and!IsActivegivesinactive. Each is a*FieldErroronexpense_account_id.UpdateSpend:LockSpend, then holder, revision and draft checks, then validate. Callworkflow.SetAmountwhen the amount changes. ThenUPDATE … revision + 1and auditupdate.BindReceipt(contract §7 order):LockSpend, then holder, revision and draft checks.doc := documents.Lock(ctx, tx, documentID).documents.ErrNotFoundgivesErrReceiptNotVerified.doc.UploadedBy != s.HolderMemberIDgivesErrReceiptNotYours. A status other than verified givesErrReceiptNotVerified.bs := documents.BoundElsewhere(ctx, tx, documentID, documents.Binding{Kind: KindSpend, RecordID: s.ID}). A non-empty result gives&BoundElsewhereError{bs}.UPDATE receipt_document_id, revision + 1, then auditbind_receipt.
ReviewSpend:LockSpend, then the revision check.- An
Outcomenot inOutcomesgivesErrOutcomeUnknown. s.Semantic != posting.AccrualgivesErrNotReviewable.in.Actor == s.HolderMemberIDgivesErrSelfReview.!mayReview(ctx, tx, c, s.InstanceID, in.Actor)givesErrNotReviewer.UPDATE reviewer_outcome, reviewed_by, reviewed_at = c.Now(), revision + 1, then audit and activity.
CanReadSpend:s.HolderMemberID == member || readAll || (s.ReviewedBy != nil && *s.ReviewedBy == member) || actsOn(…).binder_spend.go:func init() { documents.RegisterBinder(spendBinder{}) }. Its methods:Kind()returnsKindSpend.BoundToselects spends withreceipt_document_id = $2whose instance state semantic is notcancelled(joinworkflow_instanceandworkflow_state, each withorg_id = $1), as{KindSpend, id, reference}.MayReadreturns true ifmember.Can(ctx, tx, memberID, PermissionReadAll), or ifCanReadSpend(…, false)holds for any spendBoundToreturns.
-
Step 4: Run. PASS.
-
Step 5: Sabotage.
- Drop the
semantic <> 'cancelled'filter fromBoundTo.TestAVoidedSpendsReceiptIsFreeAgaingoes red. Quote it, then restore. - Remove the self-review check.
TestNobodyReviewsTheirOwnSpendstill refuses, becausemayReviewexcludes the reporter. Quote that, then also makemayReviewignore the reporter, and the test goes red. Quote it, then restore both.
- Drop the
-
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Draft petty-cash spends with their tax record and one bound receipt, and record the reviewer's outcome
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 10: The spend posting rules — (petty_cash_spend, accrual) and (petty_cash_spend, payout)
Section titled “Task 10: The spend posting rules — (petty_cash_spend, accrual) and (petty_cash_spend, payout)”Needs: Task 9 (and Task 6 only for the helpers the tests share). It runs beside Task 8.
Files:
- Create:
internal/platform/pettycash/spend_posting.go - Test:
internal/platform/pettycash/spend_posting_test.go
Interfaces:
-
Consumes:
GetSpend,usableExpenseAccount(9);ReadFloat,holderParty,ErrInstanceMismatch,ErrAmountDrift(5);accounting.PostingRule,accounting.Source,accounting.Draft;ledger.Unreversed,ledger.Get,ledger.SourceBalance,ledger.SemanticAccrual;chart.JournalPurchase,chart.JournalCash;workflow.Get. -
Produces:
type SpendAccrualRule struct{} // Kind KindSpend, Semantic Accrual: Dr expense / Cr Petty Cash APtype SpendPayoutRule struct{} // Kind KindSpend, Semantic Payout: Dr the accrual's control / Cr the float's accountvar (ErrReceiptRequired = errors.New("pettycash: a spend is recorded against its receipt")ErrNotAccrued = errors.New("pettycash: this spend has no open accrual to pay")ErrAccrualShape = errors.New("pettycash: the spend's accrual does not have a single control credit"))// func init() { spendRules = []accounting.PostingRule{SpendAccrualRule{}, SpendPayoutRule{}} } -
Step 1: Write the failing tests —
spend_posting_test.go. They calle.UseRules(t)and walk withworkflow.Transitiondirectly; the commitment and the movement are Task 11’s. The outcome is set withReviewSpend. The spend is 45.60 on 6110 withSVT8, drafted by Staff with a bound receipt.
func TestRecordingASpendAccruesToPettyCashAP(t *testing.T) { // draft -> recorded (Staff): one entry, semantic accrual, source_kind // "petty_cash_spend", source_id spend.ID, journal PUR, dated Today. // Lines: Dr 6110 45.6000 tax SVT8 party Staff's memo "PS-2026-0001 // Printer paper"; Cr 2130 45.6000 party Staff's. Nothing posted in draft.}func TestPayingASpendClearsPettyCashAPAgainstTheFloat(t *testing.T) { // Owner reviews "verified"; recorded -> spend_paid (Staff): a payout entry // on the default CASH journal: Dr 2130 45.6000 / Cr the float's account // 45.6000, both party Staff's. SourceBalance(petty_cash_spend, spend, // 2130) is 0/0.}func TestVoidingARecordedSpendReversesOnlyTheAccrual(t *testing.T) // recorded -> voided (Staff, reason): one reversal of the accrual; no payout entry everfunc TestAnUpheldSpendDisputeReversesBothLegs(t *testing.T) { // Paid; spend_paid -> disputed (Staff, reason) -> voided (Owner, reason): // two reversal entries, the payout's first (posted_seq); 6110, 2130 and // the float's account each net to zero for the source.}func TestASpendRuleRefusesAnotherInstance(t *testing.T) // Build(accrual) with a foreign InstanceID -> ErrInstanceMismatch; PRESENCE: the right Source buildsfunc TestCannotPayWhatWasNeverAccrued(t *testing.T) // SpendPayoutRule.Build on a draft spend's instance -> ErrNotAccruedfunc TestEverySpendPostingIsTraceable(t *testing.T) { // Spec 11A.7 / wave-3 §5 "the spend's receipt". For the accrual and the // payout: source_id -> GetSpend -> ReceiptDocumentID -> documents.Get is // verified with a sha256; ReviewedBy is Owner with Outcome "verified"; // workflow.Decisions names Staff for draft->recorded and // recorded->spend_paid.}-
Step 2: Run and watch fail.
-
Step 3: Implement.
SpendAccrualRule.Build:src.Control == nilgivesfmt.Errorf("pettycash: accrual: %w", chart.ErrNotFound).s := GetSpend(src.Event.SourceID). An instance mismatch givesErrInstanceMismatch.inst := workflow.Get.s.Amount.Cmp(inst.Amount) != 0givesErrAmountDrift.s.ReceiptDocumentID == nilgivesErrReceiptRequired.- Call
usableExpenseAccount, thenparty := holderParty. - Return
Draft{JournalType: chart.JournalPurchase, Currency: src.BaseCurrency, Description: "Petty cash spend " + s.Reference + " " + s.Description, Lines: [{AccountID: s.ExpenseAccountID, Debit: s.Amount, TaxCodeID: s.TaxCodeID, PartyID: &party, Memo: s.Reference + " " + s.Description}, {AccountID: src.Control.ID, Credit: s.Amount, PartyID: &party}]}.
SpendPayoutRule.Buildfollows Plan 5’sPayoutRule(internal/platform/expenses/posting.go) step for step:s; the instance check.open := ledger.Unreversed(ctx, tx, s.InstanceID). Take its oneSemanticAccrualheader, elseErrNotAccrued.ledger.Getfor its lines. Exactly one line hasCredit.IsPositive(), elseErrAccrualShape; that line’s account is the control.bal := ledger.SourceBalance(ctx, tx, KindSpend, s.ID, control), thenamount, base := bal.Amount.Neg(), bal.Base.Neg().amount.Cmp(s.Amount) != 0givesErrAmountDrift.f := ReadFloat(s.FloatID).- Return
Draft{JournalType: chart.JournalCash, Currency: src.BaseCurrency, Description: "Payment of petty cash spend " + s.Reference + " from float " + f.Code, Lines: [{AccountID: control, Debit: amount, Base: &base, PartyID: &party}, {AccountID: f.AccountID, Credit: amount, PartyID: &party}]}.
-
Step 4: Run. PASS.
-
Step 5: Sabotage. Credit the default cash journal’s account (1510) instead of
f.AccountID.TestPayingASpendClearsPettyCashAPAgainstTheFloatgoes red. Quote it, then restore. -
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Accrue petty-cash spends to Petty Cash AP and pay them from the float's own account
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 11: Spend transitions — commit, the VERIFIED gate, void by origin
Section titled “Task 11: Spend transitions — commit, the VERIFIED gate, void by origin”Needs: Tasks 6, 8 (Fund), 9, 10.
Files:
- Create:
internal/platform/pettycash/spend_transition.go - Modify:
internal/platform/pettycash/pettycashtest/env.go(addRecordedSpend,VerifiedSpend,PaidSpend) - Test:
internal/platform/pettycash/spend_transition_test.go
Interfaces:
-
Consumes:
LockSpend,GetSpend,Spend.Verified,usableExpenseAccount(9);ErrReceiptRequired(10);LockFloat,ReadFloat, errors (5);writeMovement,postedEntry,forwardMovement(6);stateOf,mayTake,reverses,TransitionInput(4);documents.Get;workflow.Transition;ledger.Get. -
Produces:
// TransitionSpend is Ruling PC17's sequence. Order: spend FOR UPDATE ->// float FOR UPDATE (entering accrual, payout, rejected or cancelled) ->// checks -> workflow.Transition -> committed and movement -> audit.func TransitionSpend(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Spend, workflow.Decision, error)var (ErrNotVerified = errors.New("pettycash: only a spend the reviewer marked VERIFIED is paid")ErrOutcomeMismatch = errors.New("pettycash: a spend is declared not reimbursable only on that review outcome"))package pettycashtest// Each walks a fresh spend of amount on f (holder = f's holder, who must// report to Owner) through TransitionSpend, each move in its own tx.func (e *Env) RecordedSpend(t *testing.T, f pettycash.Float, amount string) pettycash.Spendfunc (e *Env) VerifiedSpend(t *testing.T, f pettycash.Float, amount string) pettycash.Spend // + Owner reviews "verified"func (e *Env) PaidSpend(t *testing.T, f pettycash.Float, amount string) pettycash.Spend // + holder pays -
Step 1: Write the failing tests —
spend_transition_test.go. They calle.UseRules(t);f := e.Float(t, e.Staff)ande.Fund(t, f, "100.00").
func TestRecordingCommitsTheFloat(t *testing.T) { // DraftSpend 45.60 -> recorded (Staff): Committed 45.6000, CashBalance // 100.0000, Available 54.4000; no new movement; the audit row // ('petty_cash_spend', 'transition') names the decision and // "committed_delta": "45.6000"; activity 'recorded'.}func TestRecordingNeedsAvailableCash(t *testing.T) { // PRESENCE: 45.60 recorded. A second draft of 60.00 -> recorded -> // *InsufficientFloatError{Available 54.4000, Needed 60.0000}; still // draft; no accrual entry for it; Committed unchanged.}func TestRecordingNeedsAVerifiedReceipt(t *testing.T) // a draft with no receipt (CreateSpend only) -> ErrReceiptRequired; still draft (PRESENCE: DraftSpend records)func TestRecordingNeedsACompleteTaxRecord(t *testing.T) // a draft with a receipt but no document_basis -> *FieldError{document_basis, required}; still draft; no accrual (PRESENCE: DraftSpend records)func TestOnlyAVerifiedSpendIsPaid(t *testing.T) { // Recorded 45.60. For outcome nil, "queried" and "not_reimbursable" in turn // (Owner reviews): recorded -> spend_paid (Staff) -> ErrNotVerified, // still recorded, no payout entry. AvailableTransitions for Staff still // lists spend_paid as Allowed (contract §2.2: the route is the gate). // Then VERIFIED: paid; one spend_payout movement -45.6000 naming the // payout entry; CashBalance 54.4000, Committed 0.}func TestPayingTheWholeFloatWorks(t *testing.T) { // Fund 50 only; a spend of 50.00 recorded (Available 0), verified, paid: // CashBalance 0, Committed 0. Proves committed is released BEFORE the // cash is debited (else available would go -50 in between).}func TestVoidingARecordedSpendReleasesButNeverCredits(t *testing.T) { // Spec 8.2. Recorded 45.60 -> voided (Staff, reason): Committed 0, // CashBalance 100.0000 (unchanged), no movement; the accrual reversed.}func TestNotReimbursableNeedsThatOutcome(t *testing.T) { // Recorded; Owner reviews "verified"; recorded -> not_reimbursable (Owner, // reason) -> ErrOutcomeMismatch. Owner reviews "not_reimbursable": the move // succeeds; Committed 0; the accrual reversed; no movement.}func TestAnUpheldSpendDisputeCreditsTheFloatBack(t *testing.T) { // Paid 45.60 (cash 54.40). spend_paid -> disputed (Staff, reason) -> // voided (Owner, reason): one spend_payout_reversal +45.6000 whose // ReversesMovementID is the payout movement and whose JournalEntryID is // the payout entry's reversal; CashBalance 100.0000.}func TestADismissedSpendDisputeMovesNothing(t *testing.T) // disputed -> paid_confirmed (Owner): no movement, no entryfunc TestTheSpendLocksBeforeTheFloatAndTheFloatBeforeTheInstance(t *testing.T) { // As Task 8's lock test: A holds LockFloat; B = TransitionSpend(draft -> // recorded) blocks; probe C: petty_cash_spend FOR UPDATE NOWAIT -> 55P03, // workflow_instance FOR UPDATE NOWAIT succeeds. Bounded.}func TestConcurrentCommitmentsCannotOverdraw(t *testing.T) { // Two drafts of 60.00 on the 100.00 float, recorded from two goroutines // (10 s contexts): exactly one succeeds; the other returns // *InsufficientFloatError; Committed 60.0000.}func TestAStaleFromIsRefusedByTheEngine(t *testing.T)-
Step 2: Run and watch fail.
-
Step 3: Implement
TransitionSpend:s := LockSpend(in.ID).inst, def := stateOf(s.InstanceID); readfrom,toandknown.examine := known && inst.StateKey == in.From && mayTake(…).checked := known && (to.Semantic == posting.Accrual || to.Semantic == posting.Payout || reverses(to.Semantic)). If it holds,f := LockFloat(s.FloatID).- If
examine, run the checks for the edge:- → accrual:
fmust be open. The receipt must be present (ErrReceiptRequired) anddocuments.Getverified (ErrReceiptNotVerified). The tax record must be complete:checkTaxRecord(s.Tax, true), so a missing basis gives*FieldError{document_basis, required}(Ruling PC16). CallusableExpenseAccount.f.Available().Cmp(s.Amount) < 0gives*InsufficientFloatError. - → payout:
!s.Verified()givesErrNotVerified.fmust be open. - accrual → rejected:
s.Outcome == nil || *s.Outcome != OutcomeNotReimbursable(exact string comparison) givesErrOutcomeMismatch. - → rejected/cancelled from a non-accrual state:
fwd := forwardMovement(KindSpend, s.ID, MovementSpendPayout). Iffwd != nil && f.Status == StatusClosed, returnErrFloatClosed(PC33).
- → accrual:
d := workflow.Transition(…).- If
!examine && checked, returnworkflow.ErrStaleState. - Apply the effects, all under the float lock and before the audit:
- → accrual:
UPDATE petty_cash_float SET committed = committed + $3, updated_at = $4 WHERE org_id = $1 AND id = $2. - → payout: first
committed − amount(the same UPDATE with-). Thenf2 := ReadFloat. Thene := postedEntry(s.InstanceID, ledger.SemanticPayout). ThenwriteMovement{Float: f2, Kind: MovementSpendPayout, SourceType: KindSpend, SourceID: s.ID, JournalEntryID: e.ID, Amount: s.Amount.Neg()}. Release comes before debit (TestPayingTheWholeFloatWorks). - accrual → rejected/cancelled:
committed − amount. - → rejected/cancelled with
fwd != nil:orig := ledger.Get(fwd.JournalEntryID). Iforig.ReversedByID != nil,writeMovement{Kind: MovementSpendPayoutReversal, JournalEntryID: *orig.ReversedByID, Amount: s.Amount, Reverses: &fwd.ID}.
- → accrual:
- Write
audit.Write{EntityKind: "petty_cash_spend", Action: "transition", Before: {state}, After: {state, decision_id, committed_delta, movement_id}}, thenactivity.Record(recorded,paid,voidedortransitioned).
Add the three
pettycashtesthelpers. -
Step 4: Run. PASS.
-
Step 5: Sabotage.
- Swap the payout order (movement, then release).
TestPayingTheWholeFloatWorksgoes red with*InsufficientFloatError. Quote it, then restore. - Change the pay gate to
s.Outcome != nil(a truthiness check).TestOnlyAVerifiedSpendIsPaidgoes red, because a “not_reimbursable” spend pays. Quote it, then restore.
- Swap the payout order (movement, then release).
-
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Move spends through their workflow: commit on record, pay only VERIFIED, void by origin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 12: Replenishment — a top-up sized to every approved disbursement it restores
Section titled “Task 12: Replenishment — a top-up sized to every approved disbursement it restores”Needs: Tasks 8, 11, 14 (claims settled from the tin write claim_settlement movements). It runs beside Tasks 15 and 17.
Files:
- Create:
internal/platform/pettycash/replenish.go - Modify:
internal/platform/pettycash/topup_transition.go(the pay-time check and the release) - Test:
internal/platform/pettycash/replenish_test.go
Interfaces:
-
Consumes:
LockFloat(5); the movement kinds andMovement(6);KindTopUp,PurposeReplenishment,GetTopUp,ErrReplenishmentFixed(7);TransitionTopUp(8);workflow.Start;numbering; tests:expenses.Transitionand the Cashier claim world of Task 14. -
Produces:
type ReplenishmentLine struct {ID, TopUpID, MovementID uuid.UUIDMovementKind string // spend_payout, claim_settlement, or a *_reversal (negative line)SourceType string // petty_cash_spend | expense_claimSourceID uuid.UUIDAmount money.Amount // + restores an outflow; - returns a late credit-back (Ruling PC20)CreatedAt time.TimeReleasedAt *time.Time}// RequestReplenishment (Ruling PC20): float FOR UPDATE (serializes two// requests for one float) -> the eligible movements -> a float_topup with// purpose replenishment, amount = the lines' sum, one line per movement.func RequestReplenishment(ctx context.Context, tx pgx.Tx, c clock.Clock, floatID, actor uuid.UUID) (TopUp, []ReplenishmentLine, error)func ReplenishmentLines(ctx context.Context, tx pgx.Tx, topUpID uuid.UUID) ([]ReplenishmentLine, error)var (ErrNothingToReplenish = errors.New("pettycash: no approved disbursement of this float is awaiting replenishment")ErrReplenishmentStale = errors.New("pettycash: a disbursement this replenishment restores has since been reversed; withdraw it and request again")) -
Step 1: Write the failing tests —
replenish_test.go. They calle.UseRules(t)on Staff’s float funded 100, and on the Cashier’s float funded 500 for the claim cases (Task 14’s world: Staff’s claim of 168.00 paid from the Cashier’s float).
func TestAReplenishmentRestoresTheVerifiedPaidSpends(t *testing.T) { // PaidSpend 30.00 and 20.00; RecordedSpend 10.00 (committed). Cash 50. // RequestReplenishment(Staff): Purpose replenishment, Amount 50.0000, // Reference PT-2026-0002, Reason "Replenishment of 2 petty-cash // disbursements"; two lines, each naming its spend_payout movement (30, // 20). Owner walks it to paid: CashBalance 100.0000, Committed 10.0000.}func TestAReplenishmentRestoresAClaimSettledFromTheTin(t *testing.T) { // Controller ruling (PC20 reversed). Cashier's float: 500 funded, one // claim of 168.00 settled from it, and a PaidSpend of 12.00 (cash // 320.00). RequestReplenishment(Cashier): Amount 180.0000; two lines — the // claim_settlement movement (SourceType expense_claim, SourceID the // claim, 168.00) and the spend_payout (12.00). Paid: cash 500.0000.}func TestOnlyApprovedUnreversedDisbursementsCount(t *testing.T) { // Excluded: a recorded spend (no movement), a voided-from-recorded spend // (no movement), a not_reimbursable spend, a paid-then-upheld spend (its // payout is reversed), a claim settled from the tin whose dispute was // upheld (its settlement is reversed). Included: a paid spend whose // dispute was dismissed (paid_confirmed: unreversed).}func TestAMovementIsReplenishedOnce(t *testing.T) // a second RequestReplenishment -> ErrNothingToReplenishfunc TestNothingToReplenishOnAFreshFloat(t *testing.T) // -> ErrNothingToReplenish; no top-up row, no instancefunc TestARejectedReplenishmentReleasesItsLines(t *testing.T) { // Owner: requested->reviewed->rejected (reason): every line has // ReleasedAt; a new RequestReplenishment names the same movements again.}func TestAnUpheldReplenishmentDisputeReleasesItsLines(t *testing.T) // paid -> disputed -> rejected: topup_reversal movement AND lines releasedfunc TestAReplenishmentGoesStaleWhenItsSourceIsReversed(t *testing.T) { // Requested and approved over a paid spend and a claim settlement. The // claim's dispute is upheld (Owner) — its settlement reversed, the float // credited back. approved -> paid -> ErrReplenishmentStale; still // approved; no payout entry. PRESENCE: a disputed-but-unresolved source // does not make it stale.}func TestALateReversalReducesTheNextReplenishment(t *testing.T) { // A paid spend of 30.00 replenished and paid (cash back to 100). The // spend is then disputed and upheld: +30 credited (cash 130). A later // PaidSpend 50.00 (cash 80). RequestReplenishment: two lines — // +50.0000 (the new spend_payout) and -30.0000 (naming the // spend_payout_reversal); Amount 20.0000. Paid: cash 100.0000, the // imprest exactly. A further request -> ErrNothingToReplenish.}func TestAnOverRestoredFloatHasNothingToReplenish(t *testing.T) // the late reversal alone (sum -30) -> ErrNothingToReplenish; no lines writtenfunc TestAReplenishmentsAmountIsFixed(t *testing.T) // UpdateTopUp{Amount} -> ErrReplenishmentFixedfunc TestOnlyTheHolderRequestsAReplenishment(t *testing.T) // Owner -> ErrNotHolder-
Step 2: Run and watch fail.
-
Step 3: Implement
RequestReplenishment:f := LockFloat(floatID). Check the actor is the holder and the float is open.- Select the eligible movements in one query, with every table given
org_id = $1:
-- Positive lines: approved, unreversed outflows with no active line.SELECT m.id, m.kind, m.source_type, m.source_id, -m.amount AS line FROM float_movement m LEFT JOIN petty_cash_spend s ON s.org_id = m.org_id AND m.source_type = 'petty_cash_spend' AND s.id = m.source_id WHERE m.org_id = $1 AND m.float_id = $2 AND ( (m.kind = 'spend_payout' AND s.reviewer_outcome = 'verified') OR m.kind = 'claim_settlement') AND NOT EXISTS (SELECT 1 FROM float_movement r WHERE r.org_id = $1 AND r.reverses_movement_id = m.id) AND NOT EXISTS (SELECT 1 FROM float_replenishment_line l WHERE l.org_id = $1 AND l.movement_id = m.id AND l.released_at IS NULL)UNION ALL-- Negative lines: reversals of outflows already restored by a PAID replenishment.SELECT r.id, r.kind, r.source_type, r.source_id, -r.amount AS line FROM float_movement r JOIN float_replenishment_line l ON l.org_id = r.org_id AND l.movement_id = r.reverses_movement_id AND l.released_at IS NULL JOIN float_topup t ON t.org_id = l.org_id AND t.id = l.topup_id JOIN workflow_instance i ON i.org_id = t.org_id AND i.id = t.instance_id JOIN workflow_state st ON st.org_id = i.org_id AND st.definition_id = i.definition_id AND st.state_key = i.state_key WHERE r.org_id = $1 AND r.float_id = $2 AND r.kind IN ('spend_payout_reversal','claim_settlement_reversal') AND st.semantic IN ('payout','acknowledged') AND NOT EXISTS (SELECT 1 FROM float_replenishment_line l2 WHERE l2.org_id = $1 AND l2.movement_id = r.id AND l2.released_at IS NULL) ORDER BY 1- If there are no rows, or the sum is ≤ 0, return
ErrNothingToReplenishand write nothing. - Allocate a
PTreference. workflow.Start(KindTopUp, id, f.HolderMemberID, sum).INSERT float_topupwithpurpose = 'replenishment'and reasonfmt.Sprintf("Replenishment of %d petty-cash disbursements", positiveLines), then insert the lines. Map 23505 onfloat_replenishment_line_movement_oncetoErrNothingToReplenish(the race).- Write audit (
float_topup,create, with After naming the movement ids and amounts) and activity.
In topup_transition.go:
-
Before
workflow.Transition, whenexamineholds and the move enterspayoutwithtu.Purpose == PurposeReplenishment: no positive active line’s movement may have a reversing movement, elseErrReplenishmentStale. Use oneEXISTSquery. -
After it, on any
reverses(to.Semantic):UPDATE float_replenishment_line SET released_at = $3 WHERE org_id = $1 AND topup_id = $2 AND released_at IS NULL. -
Step 4: Run. PASS. Also run
go test ./internal/platform/pettycash/ -run 'TopUp' -count=1: Task 8’s tests stay green. -
Step 5: Sabotage.
- Drop the negative-line half of the query.
TestALateReversalReducesTheNextReplenishmentgoes red (Amount 50, not 20). Quote it, then restore. - Drop
s.reviewer_outcome = 'verified'.TestOnlyApprovedUnreversedDisbursementsCountstays green, because every paid spend is verified by the pay gate. Quote that honestly. Then also disable the pay gate (Task 11’sErrNotVerifiedcheck), and the test goes red. Quote it, then restore both.
- Drop the negative-line half of the query.
-
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Replenish floats by every approved disbursement: VERIFIED spends and claims settled from the tin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 13: Close-out — refund proof, cash back to the bank, the float closed
Section titled “Task 13: Close-out — refund proof, cash back to the bank, the float closed”Needs: Tasks 6, 8, 11.
Files:
- Create:
internal/platform/pettycash/closeout.go,internal/platform/pettycash/closeout_posting.go,internal/platform/pettycash/binder_float.go - Test:
internal/platform/pettycash/closeout_test.go
Interfaces:
-
Consumes: Tasks 4–6, 8 (
Fund), 11 (PaidSpend,RecordedSpend);documents.Lock,documents.BoundElsewhere,documents.RegisterBinder;chart.DefaultJournal,chart.JournalBank;workflow.Start,workflow.Transition. -
Produces:
type Closeout struct {ID uuid.UUIDReference stringFloatID, RequestedBy, InstanceID uuid.UUIDAmount money.AmountCurrency stringProofDocumentID uuid.UUIDState stringSemantic posting.SemanticCreatedAt time.Time}type CloseoutInput struct{ FloatID, Actor, ProofDocumentID uuid.UUID }// RequestCloseout: float FOR UPDATE -> documents.Lock -> BoundElsewhere// (contract §7; the float is the "float" binder's record) -> Start.func RequestCloseout(ctx context.Context, tx pgx.Tx, c clock.Clock, in CloseoutInput) (Closeout, error)func GetCloseout(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Closeout, error)func ListCloseouts(ctx context.Context, tx pgx.Tx, floatID uuid.UUID) ([]Closeout, error)func CanReadCloseout(ctx context.Context, tx pgx.Tx, c clock.Clock, co Closeout, member uuid.UUID, readAll bool) (bool, error)// TransitionCloseout: float FOR UPDATE (close-out rows are append-only)// -> checks -> workflow.Transition -> movement, then status -> audit.func TransitionCloseout(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Closeout, workflow.Decision, error)type CloseoutPayoutRule struct{} // Kind KindCloseout, Payout: Dr bank / Cr the float's accountvar (ErrCloseoutNotFound = errors.New("pettycash: close-out not found")ErrFloatHasCommitments = errors.New("pettycash: the float has recorded spends awaiting payment")ErrFloatEmpty = errors.New("pettycash: the float holds no cash; close it directly")ErrCloseoutStale = errors.New("pettycash: the float's cash changed since the close-out was requested")ErrProofNotYours = errors.New("pettycash: the refund proof must be the holder's own upload")ErrProofNotVerified = errors.New("pettycash: the refund proof must be a verified document"))// closeout_posting.go: func init() { closeoutRules = []accounting.PostingRule{CloseoutPayoutRule{}} }// binder_float.go: func init() { documents.RegisterBinder(floatBinder{}) } // Kind "float" -
Step 1: Write the failing tests —
closeout_test.go. They calle.UseRules(t)on Staff’s float, withFund100 andPaidSpend30 (cash 70). The proof ise.Receipt(t, e.Staff).
func TestClosingOutBanksTheRemainder(t *testing.T) { // RequestCloseout: Reference "PX-2026-0001", Amount 70.0000, State // "requested"; the float is closing with RefundProofDocumentID = proof. // Owner: requested -> confirmed: a payout entry, source_kind // "float_closeout", journal BNK: Dr 1520 70.0000 / Cr the float's // account 70.0000. One closeout movement -70.0000 naming it. The float: // closed, ClosedOn Today, 0/0. The float account's ledger balance is 0.}func TestEnteringClosingNeedsTheHoldersVerifiedRefundProof(t *testing.T) { // PRESENCE above. Owner's document -> ErrProofNotYours; Staff's pending // document -> ErrProofNotVerified; a receipt attached to Staff's claim -> // *BoundElsewhereError naming the claim. Each: the float stays open.}func TestAFloatWithCommitmentsCannotClose(t *testing.T) // RecordedSpend 10 -> ErrFloatHasCommitmentsfunc TestAnEmptyFloatHasNoCloseout(t *testing.T) // a fresh float -> ErrFloatEmpty (CloseEmptyFloat is the way, Task 5)func TestAClosingFloatTakesNoNewMoney(t *testing.T) { // While closing: CreateSpend -> ErrFloatNotOpen; a second RequestCloseout // -> ErrFloatNotOpen; an approved top-up -> paid -> ErrFloatNotOpen.}func TestWithdrawingACloseoutReopensTheFloat(t *testing.T) { // requested -> cancelled (Staff, reason): float open, refund proof NULL, // no entry, no movement. The same proof binds a new RequestCloseout // (PX-2026-0002): a withdrawn close-out no longer binds it.}func TestAStaleCloseoutIsRefused(t *testing.T) { // Request at 70. A spend paid earlier is disputed (Staff) and upheld // (Owner) during closing: +30 credited (the float is closing, not // closed). Confirm -> ErrCloseoutStale; still requested; no payout entry.}func TestAClosedFloatRefusesAReversal(t *testing.T) { // Ruling PC33. Close out fully. A spend paid before, disputed after: // uphold -> ErrFloatClosed; still disputed; no reversal entry.}func TestEveryCloseoutPostingIsTraceable(t *testing.T) { // Wave-3 §5 "the close-out's refund proof": entry source_id -> // GetCloseout -> ProofDocumentID verified with a sha256; Decisions name // Owner confirming.}func TestTheRefundProofIsReadableByTheConfirmer(t *testing.T) { // documents.MayRead(ctx, tx, c, proof, Owner) true after the request; // Cashier false; Cashier with pettycash.read_all true.}func TestTheCloseoutLocksTheFloatBeforeTheInstance(t *testing.T) // A holds LockFloat; B = confirm blocks; probe: workflow_instance FOR UPDATE NOWAIT succeeds-
Step 2: Run and watch fail.
-
Step 3: Implement
RequestCloseout:f := LockFloat. Check the actor is the holder. The float must be open:closinggivesErrFloatNotOpen,closedgivesErrFloatClosed.!f.Committed.IsZero()givesErrFloatHasCommitments.!f.CashBalance.IsPositive()givesErrFloatEmpty.doc := documents.Lock(proof). The uploader must be the holder (ErrProofNotYours) and the document verified (ErrProofNotVerified).bs := documents.BoundElsewhere(ctx, tx, proof, documents.Binding{Kind: "float", RecordID: f.ID}). A non-empty result gives*BoundElsewhereError.- Allocate:
numbering.Allocate(ctx, tx, "float_closeout", year), prefixPX. workflow.Start(KindCloseout, id, f.HolderMemberID, f.CashBalance).INSERT float_closeout, thenUPDATE petty_cash_float SET status = 'closing', refund_proof_document_id = $3, updated_at = $4.- Audit the close-out (
create) and the float (closing), then write activity.
TransitionCloseout:co := GetCloseout;inst, def := stateOf;f := LockFloat(co.FloatID); computeexamine.- If
examineand the move enterspayout:f.Status != closinggivesErrFloatNotOpen, andf.CashBalance.Cmp(co.Amount) != 0 || !f.Committed.IsZero()givesErrCloseoutStale. workflow.Transition, then the stale guard for payout.- Entering
payout:e := postedEntry(payout), thenwriteMovement{Kind: MovementCloseout, SourceType: KindCloseout, SourceID: co.ID, JournalEntryID: e.ID, Amount: co.Amount.Neg()}. ThenUPDATE … SET status = 'closed', closed_on = $today. The order matters:petty_cash_float_closed_shapeneeds the float empty. - Entering
cancelled:UPDATE … SET status = 'open', refund_proof_document_id = NULL. - Audit and activity (
closedortransitioned).
CloseoutPayoutRule.Build:co, then the instance and amount checks.f := ReadFloat.f.CashBalance.Cmp(co.Amount) != 0givesErrCloseoutStale.j := chart.DefaultJournal(bank). A nil default account givesErrNoBankAccount.- Return
Draft{JournalID: &j.ID, Description: "Close-out of petty cash float " + f.Code + " (" + co.Reference + ")", Lines: [Dr *j.DefaultAccountID co.Amount party, Cr f.AccountID co.Amount party]}.
floatBinder:BoundToselects close-outs withproof_document_id = $2whose instance semantic is notcancelled, as{Kind: "float", RecordID: float_id, Reference: the float's code}(joinpetty_cash_floatwithorg_id = $1).MayReadreturns true formember.Can(read_all),member.Can(PermissionManage), orCanReadCloseoutof any such close-out.
CanReadCloseout: requester, readAll, oractsOn. -
Step 4: Run. PASS.
-
Step 5: Sabotage. Swap step 4’s order (status first, then movement).
TestClosingOutBanksTheRemaindergoes red with 23514petty_cash_float_closed_shape. Quote it, then restore. -
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Close floats out against a refund proof: bank the remainder, empty and close the float
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 14: A claim settled from the tin — the hook in Plan 5’s code
Section titled “Task 14: A claim settled from the tin — the hook in Plan 5’s code”Needs: Tasks 6, 8, 11, 13 (the closed-float test closes a float out). It runs beside Task 16. This is the only task that edits another plan’s package, and it edits exactly contract §2.2’s list plus Ruling PC2’s one case.
Files:
- Create:
internal/platform/pettycash/settlement.go,internal/platform/expenses/pettycash_settlement_test.go - Modify (Plan 5, contract §2.2):
internal/platform/expenses/settlement.go:settle()’sErrChannelPettyCashbranch, the settlement’sCreditAccountID(the float’s account for petty cash), and theLinkSettlementcall after the insert;internal/platform/expenses/posting.go:PayoutRule.Build’sErrPettyCashDeferredbranch (now a check thats.CreditAccountIDis the payer’s float account), the cash-journal choice, and the removal ofErrPettyCashDeferred;internal/platform/expenses/errors.go: removeErrChannelPettyCash;internal/platform/expenses/transition.go: the post-payout and post-reversal side effects;internal/platform/expenses/payout_test.go:TestAPettyCashSettlementIsNeverCreditedToTheChannelAccount, rewritten under the same name;internal/platform/expenses/settlement_test.go: thepetty cashrow ofTestPlan5sOwnChannelChecks.
- Modify (Ruling PC2):
internal/modules/expenses/errors.go(the onecase, and its line in the mapping table comment),internal/modules/expenses/transition_handlers_test.go(one test)
Interfaces:
-
Consumes:
liveFloatOf,LockFloat,ReadFloat, errors (5);writeMovement,postedEntry,forwardMovement,SourceClaim,MovementClaimSettlement,MovementClaimSettlementReversal(6);ledger.Get,ledger.SemanticPayout; Plan 5:expenses.Transition,expenses.PayoutRule,expenses.Kind,settle,SettlementInThisTx; tests:pettycashtest.New(Cashier + delegation),Fund,RecordedSpend,expensestesthelpers inpayout_test.go/accrual_test.go(accruedClaim,claimNow,payoutSource,insertSettlement,sourceBalance,accountID). -
Produces (package
pettycash, the whole APIexpensesmay call):// ReserveSettlement is settle()'s petty-cash branch: it locks the payer's// live float FOR UPDATE (the claim row is already locked: claim -> float,// wave-3 §5) and checks it may pay amount now. Nothing moves until the// payout has posted in this same transaction. settle() stores the returned// Float's AccountID as the settlement's credit_account_id (Ruling PC18:// the account actually credited).func ReserveSettlement(ctx context.Context, tx pgx.Tx, payer uuid.UUID, amount money.Amount, currency string) (Float, error)// LinkSettlement records which float a claim settlement is paid from// (float_claim_settlement), in settle() right after the settlement INSERT.func LinkSettlement(ctx context.Context, tx pgx.Tx, c clock.Clock, settlementID, floatID uuid.UUID) error// SettlementAccount is the payer's open float's cash account. PayoutRule// checks the settlement's credit_account_id against it (Plan 6 P6). No lock.func SettlementAccount(ctx context.Context, tx pgx.Tx, payer uuid.UUID) (uuid.UUID, error)type SettlementRecord struct {SourceKind string // expenses.KindSourceID, InstanceID, SettlementID uuid.UUIDActor uuid.UUID}// RecordSettlement writes the claim_settlement movement for a settlement// linked to a float, against the instance's payout entry, and returns nil// for an unlinked (external) settlement. Called after workflow.Transition,// before the claim's own audit row.func RecordSettlement(ctx context.Context, tx pgx.Tx, c clock.Clock, in SettlementRecord) (*Movement, error)type HeldSettlement struct {Float FloatMovement Movement // the unreversed claim_settlement movement}// LockSettlementFloat, called BEFORE workflow.Transition on a move into// rejected or cancelled, locks the float that paid the claim (claim ->// float -> instance). Nil when no unreversed settlement movement exists.// ErrFloatClosed for a closed float (Ruling PC33).func LockSettlementFloat(ctx context.Context, tx pgx.Tx, sourceKind string, sourceID uuid.UUID) (*HeldSettlement, error)// RecordSettlementReversal credits the float back once the payout entry// has a reversal; nil (no movement) otherwise (Ruling PC14).func RecordSettlementReversal(ctx context.Context, tx pgx.Tx, c clock.Clock, h HeldSettlement, actor uuid.UUID) (*Movement, error)var (ErrFloatCurrency = errors.New("pettycash: the float does not hold the claim's currency")ErrSettlementPayerMismatch = errors.New("pettycash: the float credited is not the payer's"))In
expenses,ErrChannelPettyCashandErrPettyCashDeferredare deleted. A float refusal reaches the claim’s caller as thepettycasherror itself, wrapped with%w. -
Step 1: Write the failing tests.
internal/platform/expenses/pettycash_settlement_test.go (package expenses_test):
e := pettycashtest.New(t), withe.UseRules(t)called after any Plan 5 helper that registers its own Poster.f := e.Float(t, e.Cashier), thene.Fund(t, f, "500.00"). The top-up is reviewed, approved and paid by Owner, who is the Cashier’s superior.e.PayoutProfile(t, e.Staff, e.Channel(t, "petty_cash"), map[string]string{"payee_name": "Siti Aminah"}).- Staff’s claim is accrued at 168.00 by Plan 5’s
accruedClaimpattern.category_verified → completedis taken by Cashier, through the Owner’sexpense_claimdelegation, withPayoutChannelID= the petty-cash channel.
func TestAClaimSettledFromTheTinDebitsThePayersFloat(t *testing.T) { // The payout entry: Dr 2110 168.0000 / Cr the Cashier's float account // 168.0000, journal CSH. 1510 (the channel's CreditAccountID) is never // touched (SourceBalance(expense_claim, claim, 1510) is 0/0). One // claim_settlement movement -168.0000: source expense_claim/claim.ID, // JournalEntryID = the payout entry; CashBalance 332.0000. // The settlement row records the account ACTUALLY credited (Ruling PC18, // controller ruling 4): expenses.Settlements(claim)[0].CreditAccountID == // the float's account, not 1510; float_claim_settlement links it to the // Cashier's float; the claim's transition audit row names that // settlement, and no audit row of this transaction names 1510's id. // reconcile.Default().Run passes expense_claim_ap.}func TestTheTinMustHoldEnoughAndBeOpen(t *testing.T) { // PRESENCE: the claim above pays. Then, each on a fresh accrued claim of // 168.00, each leaving the claim category_verified with no settlement // row, no payout entry and the float unchanged: // - Owner pays through petty cash (holds no float) -> errors.Is(err, // pettycash.ErrNoOpenFloat); // - Cashier's float committed down to 100 available (RecordedSpend // 232.00) -> *pettycash.InsufficientFloatError{Available 100.0000, // Needed 168.0000}; // - Cashier's float set closing by admin (status 'closing' with a // refund_proof_document_id; committed spends do not matter to the // CHECK) -> pettycash.ErrFloatNotOpen.}func TestAnExternalPayoutWritesNoMovement(t *testing.T) // bank_transfer by Owner: no float_movement row, CashBalance unchangedfunc TestAnUpheldClaimDisputeCreditsTheFloatBack(t *testing.T) { // Paid from the tin; completed -> disputed (Staff, reason) -> rejected // (Owner, reason): Plan 4 mirrors both legs; one // claim_settlement_reversal +168.0000 reversing the settlement movement // and naming the payout's reversal entry; CashBalance 500.0000; the float // account's ledger balance equals it.}func TestAClaimPaidFromAClosedFloatCannotBeReversed(t *testing.T) { // Ruling PC33. Paid from the tin; the float closed out (RequestCloseout + // confirm, 332.00 banked). completed -> disputed (Staff); uphold (Owner) // -> pettycash.ErrFloatClosed; still disputed; no reversal entry.}func TestTheClaimLocksBeforeTheFloatAndTheFloatBeforeTheInstance(t *testing.T) { // Wave-3 §5. A holds pettycash.LockFloat(Cashier's float). B = // expenses.Transition(category_verified -> completed, Cashier, petty // cash) blocks. Probe C: expense_claim FOR UPDATE NOWAIT -> 55P03; // workflow_instance FOR UPDATE NOWAIT succeeds. Release A; B succeeds.}payout_test.go: rewrite TestAPettyCashSettlementIsNeverCreditedToTheChannelAccount in place, keeping its name and its PRESENCE half:
- PRESENCE: unchanged. An external settlement builds, and its credit is the row’s own account.
- Petty cash: open a float for the Source’s
ActorMemberIDwithpettycash.OpenFloat. No funding is needed;Buildonly checks the account. Insert the petty-cash settlement row withcredit_account_id= the float’s account, assettle()now writes it.Buildreturns a Draft whose credit line is the float’s account, notcash(1510), and whoseJournalTypeischart.JournalCash. - ABSENCE: a petty-cash row whose
credit_account_idis 1510, which is what Plan 5’s placeholder would have stored, giveserrors.Is(err, pettycash.ErrSettlementPayerMismatch)and no Draft. The same with a payer holding no float givespettycash.ErrNoOpenFloat. - In every case, no line credits 1510. Update the file’s header comment (lines 1–15) where it says Plan 8 will replace the branch.
settlement_test.go: the petty cash row of TestPlan5sOwnChannelChecks now expects pettycash.ErrNoOpenFloat, because the fixture’s payer holds no float. Its comment names Ruling PC18.
internal/modules/expenses/transition_handlers_test.go:
func TestAPettyCashRefusalNamesTheFloat(t *testing.T) { // Owner pays an accrued claim with payout_channel_id = the petty-cash // channel and holds no float: 422 whose detail starts "payout_channel_id: // you hold no open petty-cash float". PRESENCE: the same claim through // bank_transfer -> 200.}-
Step 2: Run and watch fail. Run
go test ./internal/platform/expenses/ ./internal/modules/expenses/ -run 'PettyCash|Tin|Float|ClaimDispute|Plan5sOwnChannelChecks|LocksBeforeTheFloat' -count=1and expect FAIL. -
Step 3: Implement
pettycash/settlement.go:ReserveSettlement:f := liveFloatOf(ctx, tx, payer, true), orErrNoOpenFloat.f.Status == closinggivesErrFloatNotOpen.currency != f.CurrencygivesErrFloatCurrency.f.Available().Cmp(amount) < 0gives*InsufficientFloatError.
SettlementAccount:liveFloatOf(payer, false). It must be open, elseErrNoOpenFloatorErrFloatNotOpen. Return itsAccountID.LinkSettlement:INSERT float_claim_settlement (org_id, settlement_id, float_id, created_at).RecordSettlement:in.SourceKind != SourceClaimreturns an error.SELECT float_id FROM float_claim_settlement WHERE org_id = $1 AND settlement_id = $2. No row means returnnil, nil.f := LockFloat(floatID). This is a no-op re-lock, becauseReserveSettlementtook it earlier in this transaction.f.HolderMemberID != in.ActorgivesErrSettlementPayerMismatch.e := postedEntry(in.InstanceID, ledger.SemanticPayout). Thenamt := netOn(e, f.AccountID), which is negative; zero givesErrSettlementPayerMismatch.writeMovement{Float: f, Kind: MovementClaimSettlement, SourceType: SourceClaim, SourceID: in.SourceID, JournalEntryID: e.ID, Amount: amt, Actor: in.Actor}.
LockSettlementFloat:fwd := forwardMovement(SourceClaim, sourceID, MovementClaimSettlement); nil meansnil, nil. Thenf := LockFloat(fwd.FloatID); a closed float givesErrFloatClosed.RecordSettlementReversal:orig := ledger.Get(h.Movement.JournalEntryID). IfReversedByID == nil, returnnil, nil. Otherwisef := ReadFloat(h.Float.ID), thenwriteMovement{Kind: MovementClaimSettlementReversal, JournalEntryID: *orig.ReversedByID, Amount: h.Movement.Amount.Neg(), Reverses: &h.Movement.ID}.
-
Step 4: Edit Plan 5’s code, exactly these hunks.
settlement.go: the branch, and the one line that builds the row’sCreditAccountID(controller ruling 4: “within your settle() edit”):
// Plan 8 (Ruling PC18, Plan 6 P6): petty cash is paid from the payer's // own float. Locked here — the claim row first, then the float, then // (inside workflow.Transition) the instance — and checked for available // cash. The settlement records the account ACTUALLY credited: the // float's, never the channel's cash-in-hand placeholder. credit := ch.CreditAccountID var pettyFloat *pettycash.Float if ch.SettlementBehaviour == payout.BehaviourPettyCash { f, err := pettycash.ReserveSettlement(ctx, tx, in.Actor, cl.Total, cl.Currency) if err != nil { return nil, fmt.Errorf("expenses: settle: %w", err) } credit, pettyFloat = f.AccountID, &f }Then `s := Settlement{…, CreditAccountID: credit, …}` in place of `ch.CreditAccountID`. After the `INSERT … RETURNING` succeeds: if pettyFloat != nil { if err := pettycash.LinkSettlement(ctx, tx, c, s.ID, pettyFloat.ID); err != nil { return nil, fmt.Errorf("expenses: settle: %w", err) } }posting.go: delete theErrPettyCashDeferredvar and its comment. InBuild, replace the branch and the twos.CreditAccountIDuses:
// Plan 6 P6: a petty-cash settlement is credited to the paying // holder's float account, never to the channel's cash-in-hand // placeholder. settle() already stored that account on the settlement; // this checks it is still the payer's open float before crediting it. pettyCash := snap.Channel.SettlementBehaviour == payout.BehaviourPettyCash if pettyCash { acct, err := pettycash.SettlementAccount(ctx, tx, src.Event.ActorMemberID) if err != nil { return accounting.Draft{}, fmt.Errorf("expenses: payout: settlement %s: %w", s.ID, err) } if acct != s.CreditAccountID { return accounting.Draft{}, fmt.Errorf("expenses: payout: settlement %s: %w", s.ID, pettycash.ErrSettlementPayerMismatch) } }The credit line keeps `s.CreditAccountID`. Journal selection becomes: `if pettyCash { draft.JournalType = chart.JournalCash } else { the existing loop over ListJournals, falling back to chart.JournalBank }`.errors.go: deleteErrChannelPettyCashand its comment.transition.go: insert after step 4 (settle):
// 4b. Plan 8 (Ruling PC18): a move that reverses may reverse a payout // made from a petty-cash float. Lock that float NOW — claim, float, // then the instance inside the engine (wave-3 §5) — so the credit-back // after the engine returns takes no new lock. var held *pettycash.HeldSettlement if known && (to.Semantic == posting.Rejected || to.Semantic == posting.Cancelled) { held, err = pettycash.LockSettlementFloat(ctx, tx, Kind, cl.ID) if err != nil { return Claim{}, workflow.Decision{}, fmt.Errorf("expenses: transition: %w", err) } }and after step 5b, before step 6: // 5c. The float's own ledger (wave-3 §2.2): written after the posting // and before any audit row of this transaction's own. if settlement != nil { if _, err := pettycash.RecordSettlement(ctx, tx, c, pettycash.SettlementRecord{ SourceKind: Kind, SourceID: cl.ID, InstanceID: cl.InstanceID, SettlementID: settlement.ID, Actor: in.Actor, }); err != nil { return Claim{}, workflow.Decision{}, fmt.Errorf("expenses: transition: %w", err) } } if held != nil { if _, err := pettycash.RecordSettlementReversal(ctx, tx, c, *held, in.Actor); err != nil { return Claim{}, workflow.Decision{}, fmt.Errorf("expenses: transition: %w", err) } }Extend the file's ORDER comment with "→ petty-cash float (4, 4b) → … → float movement (5c)".internal/modules/expenses/errors.go(Ruling PC2): replace theErrChannelPettyCash/ErrPettyCashDeferredcase with:
case errors.Is(err, pettycash.ErrNoOpenFloat): return apperr.New(apperr.KindValidation, "payout_channel_id: you hold no open petty-cash float to pay this claim from") case errors.Is(err, pettycash.ErrFloatNotOpen): return apperr.New(apperr.KindConflict, "payout_channel_id: your petty-cash float is closing and pays no claims") case errors.As(err, &insufficient): return apperr.New(apperr.KindValidation, fmt.Sprintf( "payout_channel_id: your petty-cash float has %s available; this claim needs %s", insufficient.Available.String(), insufficient.Needed.String())) case errors.Is(err, pettycash.ErrFloatClosed): return apperr.New(apperr.KindConflict, "the petty-cash float that paid this claim is closed; the payment cannot be reversed here")Declare `var insufficient *pettycash.InsufficientFloatError` beside the function's other `errors.As` targets. Replace the comment table's `ErrPettyCashDeferred` line with the four new rows.-
Step 5: Run. Run
go test ./internal/platform/expenses/... ./internal/modules/expenses/... ./internal/platform/pettycash/... -count=1and expect PASS. Plan 5’s full package must stay green. Every other Plan 5 test is untouched by these hunks, and a red one is a regression in this task. -
Step 6: Sabotage.
- Make
settle()’s branch leavecredit = ch.CreditAccountID.TestAClaimSettledFromTheTinDebitsThePayersFloatgoes red, becauseBuild’s check refuses withErrSettlementPayerMismatch. Then also deleteBuild’s equality check, andTestAPettyCashSettlementIsNeverCreditedToTheChannelAccountgoes red, because 1510 is credited. Quote both, then restore. - Delete step 4b.
TestAnUpheldClaimDisputeCreditsTheFloatBackgoes red (cash stays 332). Quote it, then restore.
- Make
-
Step 7: Commit.
git add internal/platform/pettycash internal/platform/expenses internal/modules/expensesgit commit -m "Settle claims from the tin: credit the paying holder's float, write its movement, credit back on reversal
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 15: Reconciliation — float_integrity, float_commitment, float_ledger
Section titled “Task 15: Reconciliation — float_integrity, float_commitment, float_ledger”Needs: Tasks 11, 13, 14. It runs beside Tasks 12 and 17.
Files:
- Create:
internal/platform/pettycash/reconcile.go - Test:
internal/platform/pettycash/reconcile_test.go
Interfaces:
-
Consumes:
reconcile.Check,reconcile.Finding,reconcile.Register,reconcile.Default;tenancy.OrgFromContext. -
Produces:
const (CheckFloatIntegrity = "float_integrity" // cash_balance = SUM(movements), every floatCheckFloatCommitment = "float_commitment" // committed = SUM(recorded-but-unpaid spends)CheckFloatLedger = "float_ledger" // account balance = cash_balance; each movement = its entry's net)func Checks() []reconcile.Check// func init() { for _, c := range Checks() { reconcile.Register(c) } } -
Step 1: Write the failing tests —
reconcile_test.go. They calle.UseRules(t). The healthy world has:- Staff’s float: funded 100; a paid spend 30; a recorded spend 10; a paid-then-upheld spend 5.
- Cashier’s float: funded 500 and paying one claim of 168.
Tampering is done as admin, on one pooled connection:
SET session_replication_role = replica(triggers off for that session only), then the statement, thenRESET session_replication_role.
func TestTheFloatChecksPassOnRealFloats(t *testing.T) { // reconcile.Default().Run(ctx, tx): the three keys are present and pass, // alongside Plan 4's and Plan 5's.}func TestAnIntegrityDriftIsNamed(t *testing.T) { // PRESENCE: passes. Tamper: UPDATE petty_cash_float SET cash_balance = // cash_balance + 1 (Staff's). float_integrity: one Finding, Ref = the // float id, Detail "stored 71.0000, movements sum 70.0000"; float_ledger // also names the float (its account says 70.0000). (Cash is 100 - 30 - 5 // + 5 = 70.)}func TestACommitmentDriftIsNamed(t *testing.T) { // Tamper: UPDATE committed = 15 on Staff's float. float_commitment: one // Finding, Ref = the float, Detail "stored 15.0000, recorded-but-unpaid // spends 10.0000". float_integrity still passes.}func TestALedgerDriftIsNamed(t *testing.T) { // Tamper: insert a 'topup' movement of +7 naming an existing unrelated // manual journal entry (the trigger off, so cash_balance does not move) // and UPDATE cash_balance by +7 — so integrity holds. float_ledger: a // Finding for the float ("account 70.0000, cash_balance 77.0000") and a // Finding whose Ref is the movement ("entry nets 0.0000 on the float // account, movement +7.0000").}func TestAClosedFloatIsCheckedToo(t *testing.T) { // Close out Staff's float; PRESENCE: passes. Tamper committed = 1 on the // closed float (constraint off): float_commitment names it (Ruling PC22).}-
Step 2: Run and watch fail.
-
Step 3: Implement. Each check is read-only and takes no lock. It reads the org from
tenancy.OrgFromContextand names it asorg_id = $1on every table. It returns oneFinding{Check, Ref, Detail}per offending row, as Plan 5’sreconcile.godoes. The queries:
-- float_integritySELECT f.id, f.cash_balance, coalesce(sum(m.amount), 0) FROM petty_cash_float f LEFT JOIN float_movement m ON m.org_id = f.org_id AND m.float_id = f.id WHERE f.org_id = $1 GROUP BY f.id, f.cash_balanceHAVING f.cash_balance <> coalesce(sum(m.amount), 0)
-- float_commitmentSELECT f.id, f.committed, coalesce(sum(s.amount) FILTER (WHERE st.semantic = 'accrual'), 0) FROM petty_cash_float f LEFT JOIN petty_cash_spend s ON s.org_id = f.org_id AND s.float_id = f.id LEFT JOIN workflow_instance i ON i.org_id = s.org_id AND i.id = s.instance_id LEFT JOIN workflow_state st ON st.org_id = i.org_id AND st.definition_id = i.definition_id AND st.state_key = i.state_key WHERE f.org_id = $1 GROUP BY f.id, f.committedHAVING f.committed <> coalesce(sum(s.amount) FILTER (WHERE st.semantic = 'accrual'), 0)
-- float_ledger, part 1SELECT f.id, f.cash_balance, coalesce(sum(l.base_debit - l.base_credit), 0) FROM petty_cash_float f LEFT JOIN journal_line l ON l.org_id = f.org_id AND l.account_id = f.account_id WHERE f.org_id = $1 GROUP BY f.id, f.cash_balanceHAVING f.cash_balance <> coalesce(sum(l.base_debit - l.base_credit), 0)
-- float_ledger, part 2SELECT m.id, m.amount, coalesce(sum(l.base_debit - l.base_credit), 0) FROM float_movement m JOIN petty_cash_float f ON f.org_id = m.org_id AND f.id = m.float_id LEFT JOIN journal_line l ON l.org_id = m.org_id AND l.entry_id = m.journal_entry_id AND l.account_id = f.account_id WHERE m.org_id = $1 GROUP BY m.id, m.amountHAVING m.amount <> coalesce(sum(l.base_debit - l.base_credit), 0)Amounts in Detail are money.Amount.String().
-
Step 4: Run. PASS.
-
Step 5: Sabotage. Make
float_commitmentcountsemantic IN ('accrual','payout').TestTheFloatChecksPassOnRealFloatsgoes red, because the paid spend now counts. Quote it, then restore. -
Step 6: Commit.
git add internal/platform/pettycashgit commit -m "Reconcile floats: stored cash against movements, commitments against spends, cash against the ledger
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 16: modules/pettycash — composition, permissions, rules at boot, idempotency, float routes
Section titled “Task 16: modules/pettycash — composition, permissions, rules at boot, idempotency, float routes”Needs: Tasks 5, 7, 9, 10, 13 (all four rules and both binders exist). It runs beside Task 14, and it is the only task of its wave touching shared files. Task 14 edits internal/modules/expenses, which is not one of them.
Files:
- Create:
internal/platform/pettycash/idempotency.go,internal/modules/pettycash/{permissions,boot,routes,http,errors,float_handlers}.go - Modify:
constants.yaml(append five permissions), thenmake generate;api/openapi.yaml(append paths and schemas), thenmake 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/platform/pettycash/idempotency_test.go,internal/modules/pettycash/{routes_test,boot_test,float_handlers_test}.go
Interfaces:
-
Consumes: Tasks 5–15;
accounting.RegisterPostingRule,accounting.DefaultRules;member.RegisterPermission,member.Can;httpx.Protected;ledger.MapError;reconcile.Default. -
Produces:
package pettycash // platform: idempotency.go — Plan 5's BeginIdempotent/FinishIdempotent, own tabletype Replay struct {Status intBody 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 errorpackage pettycash // internal/modules/pettycashconst (PermissionDisburse = "pettycash.float.disburse" // adopted Phase 0 key (Ruling PC23)PermissionManage = platformpettycash.PermissionManage // "pettycash.float.manage"PermissionRead = "pettycash.read"PermissionReadAll = platformpettycash.PermissionReadAll // "pettycash.read_all"PermissionReview = "pettycash.spend.review"PermissionTransition = "pettycash.transition")type Deps struct {DB *db.DBClock clock.ClockLogger *slog.Logger // optional; nil -> slog.Default()}func Register(g *gin.RouterGroup, deps Deps)Routes in this task (all under
/v1):
| Method | Path | Permission |
|---|---|---|
| GET | /pettycash/floats |
pettycash.read |
| POST | /pettycash/floats |
pettycash.float.manage |
| GET | /pettycash/floats/:id |
pettycash.read |
| GET | /pettycash/floats/:id/movements |
pettycash.read |
| POST | /pettycash/floats/:id/close |
pettycash.float.manage |
The float read shape (money as strings):
{ "float": { "id", "code", "holder_member_id", "holder_name", "account_id", "currency", "cash_balance": "70.0000", "committed": "10.0000", "available": "60.0000", "status", "refund_proof_document_id", "opened_on", "closed_on" } }GET /pettycash/floats?scope=mine|all: mine (the default) is the caller’s own floats; all needs pettycash.read_all or pettycash.float.manage, else 403. Movements: { "movements": [ { "id", "occurred_at", "amount", "kind", "source_type", "source_id", "journal_entry_id", "reverses_movement_id" } ] }.
- Step 1: Write the failing tests.
-
idempotency_test.go: Plan 5’s four tests against this plan’s table. They areTestAKeyReplaysItsStoredResponse,TestAKeyWithADifferentRequestIsRefused,TestAFailedRequestGivesItsKeyBackandTestConcurrentSameKeyRequestsSerialize(10 s contexts). A fifth,TestPettyCashKeysAreNotExpenseKeys, begins the same(member, key)inexpenses.BeginIdempotentand then inpettycash.BeginIdempotent; both return nil (fresh), because the namespaces are separate (contract §3). -
routes_test.go:TestEveryRouteRequiresItsExactPermission, in the shape ofinternal/modules/expenses/routes_test.go. It covers the table above, the six permission strings pinned, and gin’s table under/v1/pettycash/equal to it. Tasks 17–18 extend the table. -
boot_test.go:TestLinkingTheModuleRegistersTheFourPostingRules:accounting.DefaultRules().Lookupfinds each of (petty_cash_spend, accrual), (petty_cash_spend, payout), (float_topup, payout) and (float_closeout, payout).- The float checks register from the platform package’s
init(). Task 15’sTestTheFloatChecksPassOnRealFloatsproves it throughreconcile.Default(), so no boot test repeats it. TestLinkingRegistersTheBinders: a Staff receipt bound to a spend is reported bydocuments.BoundElsewhereaspetty_cash_spend.
-
float_handlers_test.go: the env pattern ofinternal/modules/expenses/reference_handlers_test.go, copied into this package asenv,newEnv,grant,mint,doanddecode. Its members:admin= Owner withfloat.manage,read,read_all,transition,spend.review;holder= Staff withfloat.disburse,read,transition, anddocuments.document.upload(the base’s upload permission; Ruling PC23);outsider= Cashier withreadonly.
The module is registered alone.
-
func TestFinanceOpensAFloatForAHolder(t *testing.T) { // POST /v1/pettycash/floats {"holder_member_id": Staff} as admin -> 201, // code "PF-2026-0001", cash_balance "0.0000" (a JSON string), status // "open". As holder (no manage) -> 403.}func TestAFloatIsReadByItsHolderAndFinance(t *testing.T) // holder 200; outsider 404; outsider granted read_all 200func TestListingEveryFloatNeedsReadAllOrManage(t *testing.T) // ?scope=all as holder -> 403; as admin -> both floats; ?scope=mine as holder -> onefunc TestAFloatsMovementsAreListed(t *testing.T) // after e.ex.Fund(…) through the platform: one topup movement, amount "100.0000"func TestAnEmptyFloatIsClosedOverHTTP(t *testing.T) // POST /close -> 200 status "closed"; a funded one -> 409 "…close it out instead"func TestOpeningWithoutTheFloatHeaderIs409(t *testing.T) // 1530 recoded (chart.UpdateAccount) -> 409 naming the 1530 headerfunc TestASecondLiveFloatIs409(t *testing.T)-
Step 2: Run and watch fail.
-
Step 3: Implement.
-
idempotency.go: copyinternal/platform/expenses/idempotency.go, with the tablepettycash_idempotency_keyand errors prefixedpettycash:. -
permissions.go:init()registers the six keys.member.RegisterPermissionof the already-listedpettycash.float.disbursegets the description “Hold a petty-cash float: record spends, attach receipts, request top-ups, replenishments and close-outs”.transitiongets “Use the petty-cash transition endpoints; authority to decide still comes from the workflow’s grants”. -
constants.yaml: appendpettycash.float.manage,pettycash.read,pettycash.read_all,pettycash.spend.review,pettycash.transitionafter the last line. Leavepettycash.float.disbursewhere it is. -
boot.go:init()runsfor _, r := range platformpettycash.Rules() { accounting.RegisterPostingRule(r) }. The graphs, binders and checks register from the platform package’s owninit(), which this import links. -
http.go: copy the request plumbing ofinternal/modules/expenses/http.go:decodeStrict,parseID,parseDate,actor,ifMatch,setETag,idempotencyKey,requestHash, and thepermissionLoader. A module never imports another module. -
errors.gomapErr(Tasks 17–18 extend it):Error Status Notes ErrFloatNotFound404 ErrNoFloatHeader,ErrHolderHasLiveFloat,ErrFloatNotOpen,ErrFloatClosed,ErrFloatNotEmpty409 each with its sentence ErrHolderInactive422 names holder_member_id*FieldError422 its Error()text*RevisionMismatchError409 “the record is at revision N; reload and retry” ErrIdempotencyKeyReused422 -
float_handlers.go: oneInTxper request. Visibility isCanReadFloat(f, caller, readAll || manage), else 404. -
Composition:
app.Deps.PettyCash pettycash.Deps;app.Routercallspettycash.Register(api, deps.PettyCash).composedModules["pettycash"]lists the six constants.cmd/apibuildspettycash.Deps{DB: database, Clock: clock.System(), Logger: logger}.cmd/migrate,cmd/orgadminandcmd/workerblank-importinternal/modules/pettycash. The worker runs no transition, but every binary that linksexpenses, which now linkspettycash, must also link its rules (Plan 4 R31).- The three
wiring_test.gofiles gain the module inwantModulesandpettycash.readandpettycash.transitioninwantPermissions, as the file’s own note instructs. No step key is added (Ruling PC27).
-
api/openapi.yaml: append the five operations, and the schemasPettyCashFloat,PettyCashFloatInput,FloatStatus(string enumopen, closing, closed),FloatMovementandFloatMovementKind(the seven kinds). Money fields$ref: '#/components/schemas/Money'.
-
-
Step 4: Run. Run
make generate, thengo test ./internal/modules/pettycash/ ./internal/platform/pettycash/ ./internal/app/ ./cmd/... -count=1and expect PASS. -
Step 5: Sabotage. Register only
SpendAccrualRuleinboot.go.TestLinkingTheModuleRegistersTheFourPostingRulesgoes red. Quote it, then restore. -
Step 6: Commit.
git add constants.yaml api/openapi.yaml internal/gen internal/kernel/vocab packages/contracts \ internal/app cmd internal/modules/pettycash internal/platform/pettycashgit commit -m "Compose the petty-cash module: permissions, posting rules, idempotency and float routes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 17: Spend routes — drafts, receipts, review, transitions
Section titled “Task 17: Spend routes — drafts, receipts, review, transitions”Needs: Tasks 11, 16.
Files:
- Create:
internal/modules/pettycash/spend_handlers.go,internal/modules/pettycash/transition.go(the shared idempotent-transition runner) - Modify:
routes.go,routes_test.go,errors.go,api/openapi.yaml, thenmake generate - Test:
internal/modules/pettycash/spend_handlers_test.go
Interfaces:
- Consumes: Tasks 9–11 (
CreateSpend,UpdateSpend,BindReceipt,ReviewSpend,GetSpend,ListSpends,CanReadSpend,TransitionSpend);workflow.AvailableTransitions,workflow.Decisions;BeginIdempotent,FinishIdempotent;ledger.MapError. - Routes:
| Method | Path | Permission |
|---|---|---|
| GET | /pettycash/spends |
pettycash.read |
| POST | /pettycash/spends |
pettycash.float.disburse |
| GET | /pettycash/spends/:id |
pettycash.read |
| PATCH | /pettycash/spends/:id |
pettycash.float.disburse |
| PUT | /pettycash/spends/:id/receipt |
pettycash.float.disburse |
| POST | /pettycash/spends/:id/review |
pettycash.spend.review |
| POST | /pettycash/spends/:id/transitions |
pettycash.transition |
The spend read shape (every spend response), with header ETag: "<revision>":
{ "spend": { "id", "reference", "float_id", "holder_member_id", "expense_date", "description", "amount", "currency", "expense_account_id", "tax_code_id", "supplier_name", "supplier_tin", "einvoice_uuid", "einvoice_validated_at", "document_basis", "relaxation_reason", "receipt_document_id", "outcome": "verified", "reviewed_by", "reviewed_at", "state", "semantic", "revision" }, "available_transitions": [ { "to", "action_key", "gate", "requires_reason", "allowed", "labels", "refusal" } ], "decisions": [ { "from", "to", "actor_member_id", "authority_member_id", "via", "reason", "decided_at" } ] }outcome is the OpenAPI string enum ReviewerOutcome (queried, verified, not_reimbursable, nullable) (Ruling PC7). Review body: {"outcome": "verified"}. Transition body: {"from","to","reason"?}, with Idempotency-Key required. Receipt body: {"document_id"}.
transition.go:
// runTransition is Plan 5 Task 19's handler shape, shared by spends,// top-ups and close-outs: key -> hash -> InTx{BeginIdempotent; replay or// move + read shape; FinishIdempotent} -> ledger.MapError -> mapErr.func (h handlers) runTransition(c *gin.Context, move func(ctx context.Context, tx pgx.Tx, in platformpettycash.TransitionInput) (any, error))- Step 1: Write the failing tests —
spend_handlers_test.go. It uses Task 16’senv; Staff’s float is funded throughe.ex.Fund.
func TestASpendFromDraftToPaidOverHTTP(t *testing.T) { // holder POSTs a spend (201, ETag "1", amount "45.6000"); uploads a // receipt through the platform (e.ex.Receipt); PUT /receipt with If-Match // "1" -> ETag "2"; POST /transitions {draft->recorded} with key k1 -> // state "recorded"; admin POST /review {"outcome": "verified"} If-Match // "3" -> outcome "verified"; holder POST /transitions {recorded->spend_paid} key k2 -> // "spend_paid". GET /v1/pettycash/floats/{id}: cash_balance "54.4000".}func TestMoneyAndOutcomesAreStrings(t *testing.T) { // "amount": 45.6 -> 400 naming amount; "outcome": 1 -> 400 naming // outcome; "outcome": "approved" -> 422 naming outcome; responses carry // "amount": "45.6000" and "outcome": "verified".}func TestReviewerOutcomeEnumMatchesThePlatform(t *testing.T) // the generated api.ReviewerOutcome values equal pettycash.Outcomes (Plan 6's TestSettlementBehaviourEnumMatchesThePlatform pattern)func TestTheSpendETag(t *testing.T) // PATCH without If-Match -> 400; stale -> 409 naming the revisionfunc TestPayIsRefusedUntilVerified(t *testing.T) { // recorded, outcome "queried": pay -> 422 detail "…rule=not_verified…"; // GET shows available_transitions spend_paid allowed=true for the holder // (contract §2.2: the route is the gate).}func TestReviewNeedsTheGrantNotOnlyThePermission(t *testing.T) { // outsider granted spend.review -> 403 "not the reviewer"; holder granted // spend.review -> 403 "cannot review your own spend"; admin -> 200.}func TestTheSameKeyReplaysAndDoesNotMoveTwice(t *testing.T) // replay k1 -> 200 same body, one decision; k1 with another body -> 422; no key -> 400func TestRefusalsMapToStableStatuses(t *testing.T) { // Table, accepted case first where it applies: InsufficientFloat -> 422 // naming available and needed; receipt required -> 422; receipt bound // elsewhere -> 409 naming the kind only; outcome mismatch -> 422; // float not open -> 409; stale from -> 409; refused by the workflow -> 403 // with the refusal code; reason missing on void -> 422.}func TestACommitTimeLedgerErrorIsMapped(t *testing.T) { // Wave-2 §3, Plan 5 Task 19's technique. As admin, in this database // only (dropped in t.Cleanup): a function raising 23514 CONSTRAINT // 'analytic_distribution_required' and a DEFERRABLE INITIALLY DEFERRED // constraint trigger on journal_line. PRESENCE: a spend records with the // trigger absent. Then draft->recorded -> 422 (ledger.MapError), not // 500; still draft; no entry committed.}func TestNobodyElseSeesASpend(t *testing.T) // outsider GET -> 404; with read_all -> 200; the reviewer (admin) sees it once recorded-
Step 2: Run and watch fail.
-
Step 3: Implement. Each handler runs one
InTx, and every mutation callsifMatch. The read shape is built withAvailableTransitions(caller as actor) andDecisions. Visibility isCanReadSpend(…, member.Can(read_all)), else 404.mapErrgains:Error Status Detail ErrSpendNotFound404 ErrNotHolder,ErrSelfReview,ErrNotReviewer403 ErrNotDraft,ErrNotReviewable409 ErrReceiptNotYours,ErrReceiptNotVerified,ErrReceiptRequired,ErrOutcomeUnknown,ErrOutcomeMismatch422 names the field ErrNotVerified422 field=to rule=not_verified: only a spend the reviewer marked VERIFIED is paid*InsufficientFloatError422 field=amount rule=insufficient_float: the float has <available> available; this needs <needed>*BoundElsewhereError409 names each binding’s kind only, never its reference (the base’s rule for the same refusal: the other record may be someone else’s) workflow.ErrStaleState409 workflow.ErrNoSuchEdge,ErrReasonRequired422 *workflow.RefusedError403 with the code *workflow.SubmissionRefusedError422 *fiscal.PeriodNotOpenError409 *chart.NoControlAccountError409 “accounting is not set up: no petty_cash_ap control account” *accounting.NoPostingRuleError,ledger.ErrUnbalanced500 *analytic.RequiredError,*analytic.TotalError422 a 23514 on petty_cash_spend_is_draft/petty_cash_spend_review_while_recorded409 The OpenAPI gains the seven operations, the schemas
PettyCashSpend,PettyCashSpendInput,PettyCashSpendPatch,ReviewerOutcome(string enumqueried, verified, not_reimbursable, nullable),PettyCashDocumentBasis(string enumeinvoice_company, foreign_supplier, relaxation_receipt: a spend’s own set, Ruling PC16),ReviewInput,ReceiptInputandPettyCashTransitionInput, and a sharedPettyCashAvailableTransition/PettyCashDecisionpair. -
Step 4: Run. Run
make generate && go test ./internal/modules/pettycash/ ./internal/app/ -count=1and expect PASS. -
Step 5: Sabotage. Remove the
ledger.MapErrorcall inrunTransition.TestACommitTimeLedgerErrorIsMappedgoes red with a 500. Quote it, then restore. -
Step 6: Commit.
git add api/openapi.yaml internal/gen internal/modules/pettycashgit commit -m "Serve petty-cash spends over HTTP: ETags, receipts, the tax record, review outcomes, idempotent transitions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 18: Top-up, replenishment and close-out routes
Section titled “Task 18: Top-up, replenishment and close-out routes”Needs: Tasks 12, 13, 17.
Files:
- Create:
internal/modules/pettycash/topup_handlers.go,internal/modules/pettycash/closeout_handlers.go - Modify:
routes.go,routes_test.go,errors.go,api/openapi.yaml, thenmake generate - Test:
internal/modules/pettycash/topup_handlers_test.go,internal/modules/pettycash/closeout_handlers_test.go
Interfaces:
- Consumes: Tasks 7, 8, 12, 13;
runTransition(17). - Routes:
| Method | Path | Permission |
|---|---|---|
| GET | /pettycash/topups |
pettycash.read |
| POST | /pettycash/topups |
pettycash.float.disburse |
| GET | /pettycash/topups/:id |
pettycash.read |
| PATCH | /pettycash/topups/:id |
pettycash.float.disburse |
| POST | /pettycash/topups/:id/transitions |
pettycash.transition |
| POST | /pettycash/floats/:id/replenishments |
pettycash.float.disburse |
| POST | /pettycash/floats/:id/closeouts |
pettycash.float.disburse |
| GET | /pettycash/closeouts/:id |
pettycash.read |
| POST | /pettycash/closeouts/:id/transitions |
pettycash.transition |
The top-up read shape is { "topup": { "id", "reference", "float_id", "requested_by", "purpose", "amount", "currency", "reason", "state", "semantic", "revision" }, "replenishment_lines": [ { "movement_id", "movement_kind", "source_type", "source_id", "amount", "released_at" } ], "available_transitions": […], "decisions": […] }, with an ETag.
The close-out read shape is { "closeout": { "id", "reference", "float_id", "requested_by", "amount", "currency", "proof_document_id", "state", "semantic" }, "available_transitions": […], "decisions": […] }. It has no ETag: it is append-only and has no edits.
Bodies: top-up {"float_id","amount","reason"}; patch {"amount"?,"reason"?}; close-out {"proof_document_id"}; transitions as in Task 17.
- Step 1: Write the failing tests.
topup_handlers_test.go:
func TestATopUpFromRequestToPaidOverHTTP(t *testing.T) { // holder POSTs {float, "200.00", "Weekly float"} -> 201 PT-2026-0001; // admin transitions review, approve, pay (keys t1..t3); the float shows // cash_balance "200.0000"; the movements list has one topup.}func TestAReplenishmentIsSizedByTheServer(t *testing.T) { // Two spends paid through the platform helpers; POST // /floats/{id}/replenishments -> 201, purpose "replenishment", amount // the sum, two replenishment_lines naming their movements; PATCH it -> // 409 (ErrReplenishmentFixed); a second POST -> 409 "no approved // disbursement …".}func TestAStaleReplenishmentIs409(t *testing.T)func TestOnlyTheHolderRequestsTopUps(t *testing.T) // admin lacks float.disburse -> 403 by the route; outsider granted float.disburse -> 403 ErrNotHoldercloseout_handlers_test.go:
func TestAFloatIsClosedOutOverHTTP(t *testing.T) { // Funded 100, a spend paid 30. holder POST /floats/{id}/closeouts // {proof} -> 201 amount "70.0000"; GET float -> status "closing", // refund_proof_document_id set; admin POST /closeouts/{id}/transitions // {requested->confirmed} -> 200 "confirmed"; GET float -> "closed", // closed_on today, cash_balance "0.0000".}func TestCloseoutRefusalsMapToStableStatuses(t *testing.T) { // commitments -> 409; empty float -> 409 "close it directly"; proof not // the holder's -> 422 naming proof_document_id; stale -> 409.}func TestTheRefundProofIsReadableThroughTheDocumentRoute(t *testing.T) { // Plan 5's GET /v1/documents/{id} is not mounted here; assert through // documents.MayRead(ctx, tx, c, proof, admin) after the request, as the // document route would (the binder answers).}-
Step 2: Run and watch fail. Step 3: Implement. Follow Task 17’s handler shape.
mapErrgains:Error Status Notes ErrTopUpNotFound,ErrCloseoutNotFound404 ErrReplenishmentFixed,ErrNothingToReplenish,ErrReplenishmentStale,ErrFloatHasCommitments,ErrFloatEmpty,ErrCloseoutStale409 each with its sentence ErrProofNotYours,ErrProofNotVerified422 names proof_document_idErrNoBankAccount409 “accounting is not set up: the default bank journal has no default account” a 23514 on float_topup_is_draft409 The OpenAPI gains the nine operations, the schemas
PettyCashTopUp,PettyCashTopUpInput,PettyCashTopUpPatch,ReplenishmentLine,PettyCashCloseoutandPettyCashCloseoutInput, and the enumTopUpPurpose(topup, replenishment). -
Step 4: Run. Run
make generate && go test ./internal/modules/pettycash/ ./internal/app/ -count=1and expect PASS. -
Step 5: Commit.
git add api/openapi.yaml internal/gen internal/modules/pettycashgit commit -m "Serve top-ups, replenishments and close-outs over HTTP
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 19: End to end, the roadmap, and the one full run
Section titled “Task 19: End to end, the roadmap, and the one full run”Needs: everything above.
Files:
-
Create:
internal/modules/pettycash/e2e_test.go -
Modify:
docs/ROADMAP.md -
Step 1: Write the end-to-end test. It is one test that runs the plan in sequence, over HTTP where Plan 8 has a route. Claim steps go through
platformexpenses.Transitionin a transaction, because a module does not mount another module’s routes.
func TestAFloatFromOpeningToCloseOut(t *testing.T) { // 0. Task 16's env (it wraps pettycashtest.New: Owner, Staff, Cashier, // the delegation); e.ex.UseRules(t) right after it. // 1. The org was provisioned through step 60: workflow.Latest for // petty_cash_spend, float_topup and float_closeout are v1. // 2. Admin opens a float for Staff over HTTP (PF-2026-0001) and one for // Cashier (PF-2026-0002); each has its own account under 1530 owned // by "pettycash". // 3. Top-up: Staff requests 300.00; Owner reviews, approves, pays. Cash // 300.00. Cashier's float is topped up 500.00 the same way. // 4. Spend A: Staff drafts 45.60 on 6110, binds a receipt, records // (committed 45.60), Owner reviews "verified", Staff pays. Cash 254.40. // 5. Spend B: 20.00 recorded, Owner reviews "queried": pay -> 422 // not_verified; Staff voids (reason): committed back to 0, cash // unchanged, the accrual reversed; B's receipt then backs spend C, // recorded, verified and paid (20.00). Cash 234.40. // 6. A claim settled from the tin: Staff's claim of 168.00 accrued; // Cashier pays it through the petty_cash channel (Plan 5's path): // Cashier's cash 332.00; the payout credits Cashier's float account, // never 1510. // 7. A void from spend_paid credits back: Staff disputes spend A; Owner // upholds: a spend_payout_reversal +45.60; cash 280.00; both legs of A // reversed. // 8. Replenishment: Staff requests one — it covers spend C only (A was // reversed): 20.00; Owner walks it to paid. Cash 300.00. Cashier // requests one: its line is the claim settlement of step 6 (168.00); // paid, Cashier's cash is back to 500.00. // 9. Close-out with refund proof: Staff uploads a deposit slip; POST // /closeouts -> closing at 300.00; Owner confirms: Dr 1520 / Cr the // float, closed, 0/0. // 10. The float nets: every movement's amount sums to 0 for Staff's // float; its account's ledger balance is 0; GetFloat(PF-2026-0001) // has ClosedOn today. // 11. GET /v1/accounting/reconciliation is Plan 4's route — assert // through reconcile.Default().Run instead: float_integrity, // float_commitment, float_ledger, expense_claim_accrual, // expense_claim_ap, trial_balance all pass. // 12. Traceability (spec 11A.4): every journal line on Staff's float // account resolves: entry -> source_kind/source_id -> the spend's // receipt, the top-up's approving decision, or the close-out's refund // proof, each document verified with a sha256, each decision naming // its actor; audit.VerifyChain is intact.}-
Step 2: Run it. Run
go test ./internal/modules/pettycash/ -run TestAFloatFromOpeningToCloseOut -count=1 -vand expect PASS. -
Step 3: Tick the roadmap. Under Plan 8 — Petty cash:
- Add
Plan: [2026-09-22-plan-8-petty-cash.md](superpowers/plans/2026-09-22-plan-8-petty-cash.md) (19 tasks). - Tick the three bullets, with indented notes:
- “Close-out is its own
float_closeoutworkflow kind (Ruling PC3)”; - “Replenishment restores every approved disbursement: VERIFIED spends and claims settled from the tin (Ruling PC20)”;
- “Disputes over what a closed float paid cannot be upheld in the system (Ruling PC33)”.
- “Close-out is its own
Touch no other line. The float register stays Plan 9’s line.
- Add
-
Step 4: The one full run. Run
make lint && go test -p 2 -count=1 ./...and expect every package to pass. Report a failure outside this plan’s packages with its package and test name; do not fix it by editing another plan’s code. -
Step 5: Commit.
git add internal/modules/pettycash/e2e_test.go docs/ROADMAP.mdgit commit -m "Prove a petty-cash float from opening to close-out end to end; tick Plan 8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"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 means the task waits for Task 1, which confirms the branch sits on phase1-wave3 (ec45790). Every task after Task 5 inherits B through its needs.
| Wave | Tasks | Gate | Needs | Why they are disjoint |
|---|---|---|---|---|
| 1 | 1 confirm the base · 2 migration 00150 + test world | — | — | 1: this plan document only. 2: 00150, retention_test.go, sqlc models, pettycash/doc.go, pettycashtest/ (created). |
| 2 | 3 migrations 00151–00152 · 4 graphs, rule registry, instance helpers | 4: B | 3: 2 · 4: 1, 2 | 3: its migrations, retention_test.go, sqlc models, documents_schema_test.go (its own probe graph, no seeded kind). 4: kinds.go, rules.go, instance.go, export_instance_test.go, pettycashtest (UseRules). |
| 3 | 5 floats | B | 2, 4 | float.go, errors.go, text.go, pettycashtest (Float). |
| 4 | 6 movements · 7 top-ups + rule · 9 spends, review, spend binder | — | 6: 5 · 7: 3, 4, 5 · 9: 3, 4, 5 | 6: movement.go, export_movement_test.go. 7: topup.go, topup_posting.go. 9: spend.go, spend_review.go, binder_spend.go, pettycashtest (the only one of the three to touch it). Rules are joined by per-file init() slices (Task 4), so 7 and 9/10 never share a line. |
| 5 | 8 top-up transitions · 10 spend rules | — | 8: 6, 7 · 10: 9 | 8: topup_transition.go, pettycashtest (Fund). 10: spend_posting.go. |
| 6 | 11 spend transitions | — | 6, 8, 9, 10 | spend_transition.go, pettycashtest (spend helpers). Alone. |
| 7 | 13 close-out | — | 6, 8, 11 | closeout.go, closeout_posting.go, binder_float.go. Alone. |
| 8 | 14 the expenses hook · 16 module composition + float routes | — | 14: 6, 8, 11, 13 · 16: 5, 7, 9, 10, 13 | 14: pettycash/settlement.go, internal/platform/expenses/…, internal/modules/expenses/{errors.go,transition_handlers_test.go}. 16: every shared file (constants.yaml, openapi.yaml, app.go, cmd/*), internal/modules/pettycash/…, pettycash/idempotency.go. No file in common. |
| 9 | 12 replenishment · 15 reconciliation · 17 spend routes | — | 12: 8, 11, 14 · 15: 11, 13, 14 · 17: 11, 16 | 12: replenish.go, topup_transition.go. 15: reconcile.go. 17: modules/pettycash/{spend_handlers,transition}.go, routes.go, errors.go, openapi.yaml. |
| 10 | 18 top-up, replenishment, close-out routes | — | 12, 13, 17 | openapi.yaml, routes.go, errors.go again: alone. |
| 11 | 19 end to end, roadmap, the one full run | all | 1–18 | Alone. |
Critical path: 2 → 4 → 5 → 9 → 10 → 11 → 13 → 16 → 17 → 18 → 19. Task 3 runs beside 4; 6 and 7 beside 9; 8 beside 10; 14 beside 16; 12 and 15 beside 17.
Notes for later plans
Section titled “Notes for later plans”These are 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), D2: the float register (contract §2.2, Ruling PC28).
pettycash.ListFloats(ctx, tx) ([]FloatRef, error)andpettycash.GetFloat(ctx, tx, id) (FloatRef, error)exist, withFloatRef{ID, Code, HolderMemberID, HolderName, AccountID, OpenedOn, ClosedOn}.HolderNameis the holder party’slegal_name, andOpenedOn/ClosedOnare dates.- Every float movement posts to the float’s own account. The register’s source-kind labels must know four kinds:
float_topup, which is a top-up or a replenishment. Tell them apart withpettycash.GetTopUp(ctx, tx, source_id).Purpose. A replenishment’spettycash.ReplenishmentLinesname the float movements it restored: VERIFIED spend payouts and claims settled from the tin, and negative lines for late reversals. The register can show “replenishes PS-…, EC-…” from them.petty_cash_spend: spend payouts and their reversals.expense_claim: a claim settled from the tin.float_closeout: a close-out. This is Ruling PC3’s third kind, not in the contract’s list.
- The two checks Plan 9 prints are
float_integrityandfloat_commitment.float_ledgeris a third. - Never print
cash_balance. - Plan 9’s golden float fixture can be built from
pettycashtest(New,UseRules,Float,Fund,PaidSpend, andRequestCloseout+TransitionCloseout).
- Plan 9, or whoever lands River’s pruner (Plan 5 R28’s note):
pettycash_idempotency_keyisoperationaland has no pruner yet. - Any plan that pays something else from the tin (contract §4: “paying an advance from the tin” is a follow-up):
- Reuse
pettycash.ReserveSettlement,SettlementAccount,RecordSettlement,LockSettlementFloatandRecordSettlementReversalexactly asinternal/platform/expenses/transition.godoes, in the lock order claim → float → instance. RecordSettlementaccepts onlySourceKind = "expense_claim"today. A new source needs a migration that re-statesfloat_movement_kind_known,float_movement_source_matches,float_movement_signandfloat_movement_reversal_shape, plus a matching Go constant. The migration must commute (contract §6): check that no higher-numbered migration redefines them.
- Reuse
- Whoever builds member deactivation (Plan 6 P33’s note):
- A member holding a live float should not be deactivated with cash in the tin.
- Register a
member.OnDeactivatehook that refuses whilepettycashfinds a float of that holder that is notclosed. This plan adds none, because no deactivation path exists yet. - Until then, finance closes the float first. Only the holder can request a close-out (Ruling PC21), so do it before the holder leaves.
- Readiness (Ruling PC27): an org whose chart has lost the
1530header learns it when opening a float (409). A provisioning step in120–129with a warning-onlyCheckwould surface it inGET /v1/org/readiness. - Whoever moves expense categories out of
expenses(Ruling PC16, controller ruling 3):- Spends name a GL expense account because categories are reachable only through
expenses, whichpettycashmay not import. Once categories live in a shared platform package, addcategory_idtopetty_cash_spend(additive migration, composite FK) and resolve the account through it, as claims do. - That is also when
einvoice_employeecan be allowed on a spend, under the category’semployee_named_document_allowed.
- Spends name a GL expense account because categories are reachable only through
- Phase 2 e-Invoice validation: spends carry the same tax record as claim lines, through
internal/platform/taxrecord. MyInvois validation ofeinvoice_uuidshould coverpetty_cash_spendas well asexpense_claim_line. - Plan 5’s owner (Rulings PC2, PC18):
- The
caseininternal/modules/expenses/errors.gothat answered “arrives with Plan 8” now mapspettycash.ErrNoOpenFloat,ErrFloatNotOpen,*InsufficientFloatErrorandErrFloatClosed.expenses.ErrChannelPettyCashandErrPettyCashDeferredno longer exist. - For a petty-cash settlement,
expense_claim_settlement.credit_account_idis the paying float’s account. Plan 8’sfloat_claim_settlementnames the float.
- The
Self-Review Notes
Section titled “Self-Review Notes”The plan was checked against the contract, the spec, the merged code and the built base.
Spec coverage.
- §8.1
- The float table, with every spec column and Plan 9’s dates → Task 2.
- Balance stored and locked
FOR UPDATE→ Tasks 5 and 6, and the lock tests of 8, 11, 13 and 14. cash_balance = SUM(movements)→ the trigger (Task 2) andfloat_integrity(Task 15).- Two balances, with available checked everywhere → Tasks 6, 11, 14.
open → closing → closed, with refund proof on entering closing → Task 2’s constraints and Task 13.- Movements naming their journal entry → Tasks 2, 6, 8, 11, 13, 14.
- §8.2
- The two-phase spend → Tasks 9–11.
- Reviewer outcomes are the strings
queried,verifiedandnot_reimbursable, and every gate tests== "verified"exactly → Rulings PC7–PC9 and Tasks 9, 11, 17 (with a sabotage step against a truthiness check). - Void by origin:
recordedreleases and never credits;spend_paidcredits back through the dispute → Task 11. not_reimbursableas its own terminal that reverses the accrual → Tasks 4, 10, 11.
- §8.3
float_topupwith a separate grant list → Tasks 4, 7, 8.- “The only place the balance moves” is read as “the only place it rises, besides credit-backs”, as the brief says → PC12, PC19.
- One definition merges korre’s two routes → a single kind.
- §8.4
- Replenishment counts VERIFIED spends and approved claims settled from the tin, never a reversed source → Task 12 (PC20, as the controller reversed it).
- Settlement from the tin, in the same transaction as the settlement, gated on an open float with enough available → Task 14.
- §5.5 Petty Cash AP as the spend’s control → Task 4’s
recordedstate. - §5.7
- Rule 3 under
accrual_firstandcash_only→ Task 4’s validator tests. - Rule 5 past payout → the
disputed → rejectedflags.
- Rule 3 under
- §9.5 “Float resolved at settlement” → PC18.
- §10.1 No new control kind; float accounts are owned leaves under 1530 (contract §1.5) → Task 5.
- §10.3 Four rules; reversal is Plan 4’s mirror → Tasks 7, 10, 13.
- §10.6 Float integrity and float commitment (plus
float_ledger), on demand and at period close → Task 15. - §6.7 and §11A.6 The tax record on spends (supplier TIN, e-Invoice UUID, document basis, relaxation reason), with
00072’s CHECKs mirrored → Tasks 3, 9, 11 (Ruling PC16). - §11A
- §11A.3 record text in Go and in a CHECK → Tasks 3, 5, 7, 9.
- §11A.4 and §11A.7: every rule test asserts a path to the document and the decision → Tasks 7, 10, 13, 14, 19.
- §11A.2 retention lists → Tasks 2, 3.
- §20
- §20.1.9 reviewer outcomes and float statuses as OpenAPI enums → Tasks 16, 17.
- §20.2 step 7: three kinds seeded and published at v1 by step 60 → Task 4.
- §20.3 no central seeding edit →
RegisterSeededGraph.
- Contract §2.2, line by line
- Kinds and orders → Task 4.
- Outcomes with a route gate → Tasks 11, 17. They are strings by controller ruling, and the contract is amended.
- Void via §1.1 → Tasks 10, 11.
- The float ledger written after
Transition, before audit → Tasks 8, 11, 13, 14. - The hook edits limited to the list → Task 14 (plus PC2).
- Credit to the holder’s float, never
CreditAccountID→ Task 14’s test and its sabotage step. expensesimportspettycash, never the reverse → Global Constraints; the compiler enforces it.- Plan 9 names → Task 5.
OwnerModuleleaves under 1530, refusing without the header → Task 5.- The placeholder permission → PC23.
- Contract §3–§5
- Ranges → Global Constraints.
- Reconcile keys → Task 15.
- Binder kinds → Tasks 9, 13.
- Its own idempotency table → Tasks 2, 16.
- Lock order → Global Constraints, PC13, and the four lock tests.
- Instances are started in an earlier transaction → every create path, and the fixtures.
- Presence before absence and sabotage → every task.
- Contract §6 and §7 (amended at
ec45790): migrations commute (Global Constraints);MayReadtakes a clock (Tasks 9, 13); binding locks record → document (Tasks 9, 13, PC13, PC31). - Brief’s end-to-end list → Task 19, steps 2–12. Open, top-up, spend recorded, verified, paid, a claim settled from the tin, a void from
spend_paidcrediting back, close-out with refund proof, the float netting, every check clean. - Not covered, by ruling:
- upholding disputes after a float closes (PC33);
- a readiness step (PC27).
Placeholder scan. No “TBD” or “TODO”. Four places point at real, merged code to copy rather than repeating it, as Plans 5 and 6 did:
- Plan 5’s
mayTake(Task 4); - Plan 5’s
PayoutRule(Task 10); - Plan 5’s
idempotency.goand modulehttp.go(Task 16); - Plan 5’s module test env (Task 16).
Task 9 names the tax-code reader “as built in chart/taxcode.go”, because its exact function name was not needed for any signature here. The implementer reads it there.
Type consistency.
TransitionInput(Task 4) is the input ofTransitionTopUp(8),TransitionSpend(11) andTransitionCloseout(13), and ofrunTransition’s callback (17).Float/FloatRef(5) are used unchanged by 6–16.movementInput.Floatis always the float as it stands now. Task 11 re-reads it after releasingcommitted.writeMovementreturns(Movement, Float, error)everywhere.Outcomeisstringin Go (Outcomesis the set),TEXTwith a named CHECK in SQL, and a string enum on the wire, pinned by two tests.TaxRecord(Task 9) is used byCreateSpend,UpdateSpend,checkTaxRecordandTransitionSpend’s record check (Task 11). Its columns mirror00072’s names.SettlementRecord.SettlementID(Task 14) is thefloat_claim_settlementkey written byLinkSettlementinsettle().Spend.Verified()is the only gate predicate (Tasks 11, 17).KindSpend/KindTopUp/KindCloseoutare the workflow kind, thejournal_entry.source_kind, thefloat_movement.source_typeand the numbering-scope family.SourceClaimis"expense_claim", which equalsexpenses.Kind.- The error sentinels are declared once, where the Interfaces blocks say: common ones in
errors.go(Task 5), document-specific ones in their own files, andErrNoBankAccountin Task 7, used again by Task 13. - The rule types are
TopUpPayoutRule(7),SpendAccrualRule/SpendPayoutRule(10) andCloseoutPayoutRule(13). Each is added toRules()through its own slice. - The
pettycashtesthelpers are added in the task that first needs them and are used only later:UseRules(4),Float(5),Fund(8),Receipt/PendingReceipt/DraftSpend/Grant(9),RecordedSpend/VerifiedSpend/PaidSpend(11). - Base symbols match the Reconciliation record below:
documents.MayRead(ctx, tx, c, …),chart.AccountInput.OwnerModule *string,*ledger.OwnedAccountManualError{Code, Module},workflow.Instance.PayoutOpen,workflowtest.RequireSeededGraphValid(t, kind),payout_on_cycle,accrual_in_cash_only.
Controller rulings applied (2026-09-22).
-
Accepted: PC2 (the one mapping case plus its handler test in
modules/expenses), PC3 (float_closeout,cash_only), PC4 (paid_confirmed) and PC33. The controller amends contract §2.2 to match. -
PC7 reversed: reviewer outcomes are strings (
queried,verified,not_reimbursable),TEXT NULLwith a named CHECK, and an OpenAPI string enum. Gates compare exactly, andTestOnlyAVerifiedSpendIsPaidproves NULL,queriedandnot_reimbursableare refused. -
PC16 changed: spends capture the tax record through
internal/platform/taxrecord(reconciled by Task 1), with00072’s CHECKs mirrored. The GL expense account stays, and sharing categories is a note for later. -
PC18 changed: the settlement row records the float’s account as
credit_account_id, and Plan 8’sfloat_claim_settlementlinks it to the float.PayoutRulechecks that account is the payer’s float. -
Documents: uploads need
documents.document.upload, which Task 16’s holder is granted.GETandcompleteare authenticated-only, with the binders deciding. PC30 stands. -
PC34: the bound-elsewhere 409 names the kind only.
-
PC5 accepted as written.
-
PC20 reversed: replenishment restores claims settled from the tin as well as VERIFIED spends. Lines name movements; a reversed source is never replenished; a late reversal becomes a negative line on the next replenishment. Task 12 moves after Task 14.
Nothing is left open for the user.
Reconciliation record
Section titled “Reconciliation record”Read from phase1-wave3 at ec45790 on 2026-09-22, before any task ran. Task 1 re-checks it after the rebase.
| Symbol | As built | Where |
|---|---|---|
posting.Event.PastPayout |
bool. PostsToLedger() is true on Rejected/Cancelled with PastAccrual || PastPayout. |
internal/platform/posting/posting.go |
workflow.Instance.PayoutOpen |
bool, column payout_open. Transition fills Event.PastPayout from the value before the move. |
internal/platform/workflow/instance.go:61, 353, 408 |
workflow.CashOnly |
PostingOrder = "cash_only" |
internal/platform/workflow/model.go |
| Validator | Rule 3(a) code payout_on_cycle; rule 3(c) under cash_only is accrual_in_cash_only; rule 5 counts payout states too. The edge flag is still EdgeSpec.ReversesAccrual. |
internal/platform/workflow/validate.go:279, 350, rule5 |
workflow.RegisterSeededGraph(kind string, build func(ownerSeat uuid.UUID) Graph) |
Panics on a bad key, on expense_claim, on a duplicate, on a nil build, and on a built kind ≠ kind. Step 60 seeds registered kinds with no version. |
internal/platform/workflow/seed.go |
workflowtest.RequireSeededGraphValid(t TB, kind string) |
Fails if the kind is not seeded or not valid under "MYR". |
internal/platform/workflow/workflowtest/fixture.go:159 |
documents.Binder |
Kind() string; BoundTo(ctx, tx, documentID) ([]Binding, error); MayRead(ctx, tx, c clock.Clock, documentID, memberID) (bool, error). The clock parameter is contract §7. |
internal/platform/documents/binder.go |
documents.Binding, RegisterBinder, BoundElsewhere, MayRead |
Binding{Kind, RecordID, Reference}. RegisterBinder panics on nil, a blank kind or a duplicate. BoundElsewhere(ctx, tx, documentID, except Binding) ignores except.Reference. MayRead(ctx, tx, c, documentID, memberID) returns documents.ErrNotFound for an unknown document. |
same |
| Expense binder | Kind expense_claim. It binds active line attachments and settlement proofs, and leaves expenses.claim.read_all to its route. AttachDocument locks claim → document → BoundElsewhere. |
internal/platform/expenses/binder.go, attachment.go |
expenses.ErrDocumentBoundElsewhere, *expenses.DocumentBoundElsewhereError |
exist | internal/platform/expenses/attachment.go:61, 75 |
chart.AccountInput.OwnerModule, chart.Account.OwnerModule |
*string. chart.ErrUnknownOwnerModule exists. The CHECK account_owner_module_known admits 'pettycash'. account_immutable_guard freezes owner_module. |
internal/platform/accounting/chart/account.go:71, 92, db/migrations/00054_account_owner_module.sql |
| Manual line on an owned account | *ledger.OwnedAccountManualError{Code, Module} |
internal/platform/accounting/ledger/errors.go:54, post.go:231 |
| §1.1 proofs | TestACashOnlyDocumentPostsItsOneCashLegAndReversesIt, TestAPayoutFirstDocumentRejectedAfterItsPayoutReversesIt |
internal/app/posting_cashleg_test.go |
| Unchanged by the base (Task 14 edits these) | settle()’s ErrChannelPettyCash branch; PayoutRule.Build’s ErrPettyCashDeferred branch; the case in internal/modules/expenses/errors.go (about line 276); the petty cash row of TestPlan5sOwnChannelChecks |
as named |
| Contract amendments | §6: migrations apply with allow-missing, and each must commute with every higher-numbered one. §7: MayRead takes a clock; binding locks record → document → BoundElsewhere; owner_module is frozen by trigger. |
docs/superpowers/specs/2026-09-22-phase1-wave3-contracts.md on phase1-wave3 |
| Pending, still being built on the base (Task 1 records these) | internal/platform/taxrecord (record type, validator, rule codes, basis constants); the documents.document.upload permission and authenticated-only GET/complete; the kind-only bound-elsewhere 409 wording |
phase1-wave3 after ec45790 |