Skip to content

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/pettycash owns 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/expenses calls. expenses imports pettycash; pettycash never imports expenses.
  • internal/modules/pettycash is the HTTP shell for /v1/pettycash/… and registers the posting rules at boot.
  • The database enforces what must never drift:
    • cash_balance moves only through a float_movement insert (a trigger), and a direct update is refused;
    • available cash (cash_balance − committed) and committed can 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):

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/… and internal/modules/pettycash/…, plus the §2.2 edits in internal/platform/expenses and 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.
    • pettycash never imports internal/platform/expenses. The compiler enforces this from Task 14 on, because expenses imports pettycash and a cycle would not build.
  • Ranges.

    • Migrations 00150–00169; this plan uses 00150–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, and float_closeout. The third is added by Ruling PC3.
  • Shared files are append-only (wave-1 §3, extended):

    • the constants.yaml permissions list, api/openapi.yaml, internal/app/app.go, internal/app/routes_test.go (composedModules);
    • the module imports in cmd/{api,migrate,orgadmin,worker}/main.go and cmd/{migrate,orgadmin,worker}/wiring_test.go;
    • the internal/kernel/db/retention_test.go lists;
    • docs/ROADMAP.md: tick only Plan 8’s lines.

    Generated files are regenerated with make generate, never hand-merged.

  • Tenant isolation, both layers.

    • Every new table has org_id UUID NOT NULL, ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid.
    • Every SQL statement names org_id = $n for each org-scoped table it touches (join equalities count), and every INSERT names org_id.
    • Every FK between two org-scoped tables is composite: (org_id, x) REFERENCES t (org_id, id).
  • Named constraints (wave-2 §3). Every CHECK, UNIQUE and EXCLUDE is written CONSTRAINT <name> …, and no name ends in _check, _key or _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_temp and schema-qualifies every table.

  • No SET CONSTRAINTS in 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-level UPDATE (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 → the petty_cash_float row FOR UPDATE → the workflow_instance row (inside workflow.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 calls documents.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 before workflow.Transition in a transaction that transitions. This plan adds no advisory-lock class.

  • Migrations commute (contract §6). cmd/migrate applies up with goose’s allow-missing option, so a migration numbered below one already applied can still run. Plan 8’s migrations only create objects, prefixed petty_cash_, float_ or pettycash_. 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, as 00072 does.

  • Commit-time errors. Every handler whose transaction can post passes db.InTx’s error through ledger.MapError before 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.Start and workflow.SetAmount audit. 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 with Cmp, 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). Never time.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 named CHECK using !~ '[぀-ヿ㐀-䶿一-鿿가-힯]' (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 as field=<name> rule=<code>: <sentence> (Plan 5 R43).

  • Every route goes through httpx.Protected with a pettycash.* permission registered in the module’s init(), and is documented in api/openapi.yaml in the same commit.

  • posting and accounting.DefaultRules() are process-global. A test that needs a Poster calls e.UseRules(t) (Task 4), which registers Plan 4’s real accounting.NewPoster over a fresh RuleSet holding expenses.Rules() and pettycash.Rules(). Never use t.Parallel().

  • All tests of internal/platform/pettycash are external (package pettycash_test). pettycashtest imports expensestest, which imports expenses, which imports pettycash. An internal test file importing pettycashtest would be a cycle. Internals reach tests through export_test.go only.

  • 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.WithTimeout and clean up in t.Cleanup.

  • Tests: during development, run only the packages you changed (go test ./internal/platform/pettycash/... -count=1). Run make generate after any migration, OpenAPI or constants.yaml change. Run one full go 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>


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 like expensestest) and one module, internal/modules/pettycash.
    • pettycashtest.Env embeds *expensestest.Env, a test-only import of expenses, and adds a Cashier (Ruling PC18).
    • Every pettycash test 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 expensestest changes shape, pettycashtest changes 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 case in internal/modules/expenses/errors.go (the ErrChannelPettyCash/ErrPettyCashDeferred case) and adds one test to internal/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 case anyway.
    • Cost if wrong: if the user refuses, Plan 8 must keep ErrChannelPettyCash and 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 account for the frozen amount.
    • Why: spec §8.1 and D2 require the closing → closed posting, “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.Post refuses a manual line on an owned account (contract §1.5).
      • An accrual or payout entry needs a workflow instance (journal_entry_workflow_source, 00052).
      • journal_entry.semantic is 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.
  • PC4 Ruling (contract problem — ACCEPTED by the controller; contract §2.2 amended to match): Each of petty_cash_spend and float_topup gains one state, paid_confirmed (semantic acknowledged, terminal), as the target of a dismissed dispute.

    • Why: the contract makes spend_paid and paid terminal payout states and lists disputed. 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_paid is refused, and the Poster’s once-only rule R8 would refuse the re-entry anyway.
    • 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_confirmed is reached only by dismissal, posts nothing, and may itself be disputed again.
    • Task 4’s TestADismissedDisputeCannotReturnToAPayoutState proves 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.
  • 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 → recorded reporter record; accrual, control petty_cash_ap
      recorded → spend_paid reporter pay; payout, terminal. The holder hands the cash out
      recorded → voided reporter void; reason required, reverses
      recorded → not_reimbursable target reject; reason required, reverses
      spend_paid → disputed reporter reason required
      paid_confirmed → disputed reporter reason required
      disputed → voided source uphold; reason required, reverses
      disputed → paid_confirmed source dismiss
      • Voiding from spend_paid goes through disputed, 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/PastAccrual make it fire.
    • float_topup (cash_only):

      Edge Gate Notes
      requested(draft) → reviewed(pending) target review
      reviewed → approved(pending) target approve
      approved → paid target pay; payout, terminal
      requested → cancelled reporter withdraw; reason required
      reviewed → rejected advance reason required
      approved → rejected advance reason required
      paid → disputed reporter reason required
      paid_confirmed → disputed reporter reason required
      disputed → paid_confirmed source dismiss
      disputed → rejected source uphold; reason required, reverses

      requested is 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 → confirmed is 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_spend not_reimbursable the holder’s superior
    petty_cash_spend disputed the root seat
    float_topup reviewed superior
    float_topup approved unit_head
    float_topup paid the root seat
    float_topup disputed the root seat
    float_closeout confirmed the 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 NULL with CONSTRAINT petty_cash_spend_outcome_known CHECK (reviewer_outcome IN ('queried','verified','not_reimbursable')). NULL means not yet reviewed, which is distinct from queried.
    • In Go: type Outcome string with OutcomeQueried, OutcomeVerified, OutcomeNotReimbursable, and Outcomes (the closed set). On the wire: the OpenAPI string enum ReviewerOutcome (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) and TestReviewerOutcomeEnumMatchesThePlatform (Task 17). It crosses the wire, so §20.1.9 puts it in api/openapi.yaml, not constants.yaml.
    • Every gate tests == OutcomeVerified exactly. 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, queried and not_reimbursable.

    Cost if wrong: none; the contract’s §2.2 is amended by the controller to match.

  • PC8 Ruling: Who reviews a spend.

    • The reviewer is any member whom workflow.AvailableTransitions shows as Allowed on an edge from the spend’s current state into a rejected-semantic state. In the seed that is recorded → not_reimbursable, granted to the holder’s superior.
    • The reviewer is never the holder (ErrSelfReview).
    • The permission pettycash.spend.review only 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_reimbursable edge 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.

  • PC9 Ruling: Route gates, decided by semantics, not by state keys (contract §2.2: “the route is the gate”).

    • An edge from an accrual state into a payout state (pay) requires reviewer_outcome = 'verified' exactly (ErrNotVerified).
    • An edge from an accrual state into a rejected state (not reimbursable) requires 'not_reimbursable' (ErrOutcomeMismatch).
    • An edge into cancelled (void) is open whatever the outcome. queried means “void and re-record”.
    • AvailableTransitions still lists every edge unchanged, and the read shape adds outcome so a client can grey the pay button.

    Cost if wrong: none; the gate is one comparison.

  • PC10 Ruling: At most one live (not closed) float per holder (partial unique index petty_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.CreateAccount when the float opens:

    • parent = the active header whose code is 1530 (contract §2.2); refuse with ErrNoFloatHeader (409) if it is missing, inactive, postable, or not an asset;
    • Type asset, Currency = the base currency, OwnerModule: "pettycash" (contract §1.5);
    • code 1530.<float code> (for example 1530.PF-2026-0001, within account_code_format); name Petty 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.

  • PC12 Ruling: Balances are stored and moved only in a movement’s transaction.

    • cash_balance is changed only by the float_movement AFTER INSERT trigger float_movement_apply. The BEFORE UPDATE OF cash_balance trigger petty_cash_float_balance_guard refuses any direct change with petty_cash_float_balance_by_movement. So cash_balance = SUM(movements) is a database fact, and float_integrity is defence in depth.
    • committed has 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 implies cash_balance ≥ 0).
    • Go checks both under the lock first, so a caller gets a typed *InsufficientFloatError rather 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 UPDATE grant, so no FOR 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 with FOR UPDATE NOWAIT while 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.Transition returns and before this plan’s own audit.Write. Its journal_entry_id is found through ledger:
      • a forward leg uses the instance’s unreversed entry of the semantic just posted;
      • a reversal uses that entry’s ReversedByID from ledger.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 recorded spend releases committed and writes no movement.

    Cost if wrong: none; the ledger is the source of truth.

  • 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 (with tax_code_id and memo <ref> <description>) the resolved petty_cash_ap control default 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 bank journal’s default account that journal
    (float_closeout, payout) the default bank journal’s default account the float’s account the default bank journal
    • 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_supplier or relaxation_receipt);
      • relaxation_reason.

      The base branch is extracting taxrecord from expenses now, and Task 1 reconciles its exact names. Migration 00151 mirrors 00072’s named CHECKs on the same columns, citing each.

    • einvoice_employee is 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_basis is optional in draft and required to enter recorded, with completeness checked by taxrecord then, as claim submission does.

    • expense_account_id must be an active, postable expense account (via chart.GetAccount). A spend has an optional tax_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 enter recorded, and replaced only in draft (DB trigger).

    Why a GL account and not a category: categories live in expense_category, reachable only through expenses, which pettycash may not import (contract §2.2). Recorded in Notes for later plans: share categories once they move out of expenses. 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 ≥ amount under the lock, then committed += amount.
    • Entering payout (pay) pays: movement spend_payout of −amount, then committed −= amount.
    • Entering cancelled/rejected from an accrual state releases: committed −= amount, no movement.
    • Entering cancelled from a dispute over a paid spend: movement spend_payout_reversal of +amount, reversing the payout movement.
    • Recording and paying need the float open. A reversal needs it not closed (PC33).

    Cost if wrong: none; this is §8.1–8.2 verbatim.

  • 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 is Event.ActorMemberID at posting).

    • settle()’s petty-cash branch does two things:

      • It calls pettycash.ReserveSettlement. This locks the payer’s live float FOR UPDATE (claim → float: the claim row is already locked) and requires it open, in the claim’s currency, with available ≥ 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.LinkSettlement writes Plan 8’s own link row float_claim_settlement (settlement_id → float_id), so 00073 is 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.

    • PayoutRule.Build’s petty-cash branch credits s.CreditAccountID, which is now the float’s account, on the default cash journal. First it checks that account equals pettycash.SettlementAccount(payer); a mismatch gives pettycash.ErrSettlementPayerMismatch. So a settlement row naming 1510 for petty cash cannot post. The debit side and SettlementInThisTx are unchanged.

    • After workflow.Transition, pettycash.RecordSettlement(settlementID) writes the claim_settlement movement 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.LockSettlementFloat runs before workflow.Transition (claim → float → instance). After the transition, RecordSettlementReversal credits 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_claim delegation from the Owner. This is the realistic shape, because the seeded completed grant 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 float open and writes movement topup of +amount.
    • An upheld dispute (entering rejected past payout) reverses the leg. It needs available ≥ amount, because the cash must still be in the tin (*InsufficientFloatError otherwise), and writes topup_reversal of −amount.
    • Every entry into rejected/cancelled releases 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_topup with purpose = '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_payout whose spend is reviewer_outcome = 'verified' (spec §8.4), or a claim_settlement. A claim settlement comes from Task 14’s float_claim_settlement link, 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_reversal or claim_settlement_reversal whose 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 gives ErrNothingToReplenish, 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 = 0 and cash_balance > 0.
    • The amount is frozen at the current cash_balance. In the same transaction the float enters closing with refund_proof_document_id set (constraint petty_cash_float_refund_proof_when_closing).
    • Confirming (entering payout) posts PC15’s close-out rule. It refuses ErrCloseoutStale if cash_balance ≠ amount (a reversal moved it meanwhile), writes movement closeout of −amount, and then sets closed and closed_on (constraint petty_cash_float_closed_shape requires an empty float).
    • Withdrawing returns the float to open and clears the proof.
    • An empty float (cash_balance = 0, committed = 0) is closed directly by pettycash.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 an accrual-semantic state.
    • float_ledger has two parts:
      • the float account’s base balance in journal_line equals cash_balance;
      • every movement’s journal entry nets exactly the movement’s amount on the float account.
    • 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.disburse as the holder’s permission: create and edit spends, bind receipts, request top-ups, replenishments and close-outs.

    • Append five:

      Permission Grants
      pettycash.float.manage open a float; close an empty one; list every float
      pettycash.read read floats, spends, top-ups and close-outs you hold, may act on, reviewed, or appear in
      pettycash.read_all read every one
      pettycash.spend.review use the review route
      pettycash.transition use 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) needs documents.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/:id and …/complete are authenticated-only, and the data rule (documents.MayRead, through the binders) decides.

    Why: constants.yaml is 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 of role_permission rows.

  • 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, its reviewed_by. Anyone else gets 404.
    • A float is readable by its holder, pettycash.read_all and pettycash.float.manage.
    • The binders’ MayRead uses the same functions.

    Why: this is Plan 5 R21’s pattern. Cost if wrong: none.

  • PC25 Ruling: Idempotency and ETags.

    • Idempotency-Key is required on the three transition routes. It is stored in this plan’s own table pettycash_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.Allocate in the creating transaction, per calendar year:

    Record Scope Prefix
    Float petty_cash_float PF
    Spend petty_cash_spend PS
    Top-up float_topup PT
    Close-out float_closeout PX

    A float’s code is its reference. Cost if wrong: none (§20.1.3).

  • PC27 Ruling: Plan 8 registers no provisioning step. Orders 120–129 stay unused.

    • The three graphs are seeded and published at v1 by step 60, through workflow.RegisterSeededGraph from pettycash’s init() (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 1530 refuses to open one (409).

    Cost if wrong: an org whose chart lost 1530 learns it when opening a float, not from readiness. A later readiness warning is one step.

  • PC28 Ruling: Plan 9 names (contract §2.2, D2).

    • pettycash.FloatRef{ID, Code, HolderMemberID, HolderName, AccountID, OpenedOn, ClosedOn}, with HolderName = the holder party’s legal_name, and ListFloats(ctx, tx) ([]FloatRef, error) and GetFloat(ctx, tx, id) (FloatRef, error).
    • The float register’s source kinds are petty_cash_spend, float_topup, float_closeout and expense_claim.
    • A replenishment is a float_topup. Plan 9 tells it apart with GetTopUp(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.Record rows 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_all themselves. Plan 5’s own binder leaves expenses.claim.read_all to its route. But the only document read route is Plan 5’s /v1/documents/:id, which knows claim permissions only, so a pettycash.read_all holder could never open a spend’s receipt unless the petty-cash binder asks member.Can(ctx, tx, member, PermissionReadAll). Both binders use the clock documents.MayRead passes (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.BoundElsewhere is called with except = 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. A not_reimbursable spend 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.

  • PC32 Ruling: Decision on the placeholder is covered by PC23 (adopted). The permission’s description in permissions.go says what it now grants.

  • PC33 Ruling (ACCEPTED by the controller): A reversal that would move a closed float 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. Entering closing needs committed = 0 only.

    • 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.
  • 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 *BoundElsewhereError still carries full documents.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 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).
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.
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-pettycash onto phase1-wave3 before building. If that has not happened, run git fetch && git rebase phase1-wave3, or rebase onto main once the base has merged. The user merges; you only rebase this branch. Then run git merge-base --is-ancestor ec45790 HEAD and 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-advances to confirm Plan 7 has not claimed Ruling PC3’s kind or any pettycash.* name.

    • internal/platform/taxrecord (the base’s extraction from expenses, not yet on ec45790; 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’s TaxRecord mapping, checkTaxRecord and TestASpendCarriesTheTaxRecord to call it exactly. Confirm that it imports nothing from expenses, so pettycash may import it. If the package has not landed when Task 9 starts, stop and report: do not copy Plan 5’s line rules into pettycash.
    • The document routes (controller ruling 5): POST /v1/documents requires documents.document.upload, seeded on every filing role. GET /v1/documents/:id and POST …/complete are authenticated-only, and documents.MayRead decides. 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 *BoundElsewhereError mapping.
    • 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:
Terminal window
git add docs/superpowers/plans/2026-09-22-plan-8-petty-cash.md
git 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, then make 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 pettycashtest
    var T0 = expensestest.T0 // 2026-09-21 09:00 UTC
    var Today = expensestest.Today // 2026-09-21
    type 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 Down
DROP 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):

    1. ex := expensestest.New(t).
    2. cashier, seat := ex.Member(t, "Cashier", &ex.OwnerSeat).
    3. In one ex.Tx, call workflow.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"}).
    4. Return &Env{Env: ex, Cashier: cashier, CashierSeat: seat}.

    FloatHeader: chart.GetAccountByCode(ctx, tx, "1530") inside e.Tx.

  • Step 4: Write the failing tests — float_schema_test.go (package pettycash_test). The helpers are local to this file and prefixed schema:

    • schemaAccount(t, e, code) creates a postable asset leaf under 1530 with chart.CreateAccount and no OwnerModule. The base need not be built yet, and the FK only needs an account.
    • schemaFloat(t, e, holder, status) inserts a row as erp_app in e.Tx, with id uuid.New() and code "PF-T-" + short.
    • schemaEntry(t, e) posts one manual entry through ledger.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 -> 42501
func TestIdempotencyKeysArePrunable(t *testing.T) // erp_app DELETE succeeds
func 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 run go test ./internal/platform/pettycash/... ./internal/kernel/db/ -count=1 and 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() < 2 to < 1. TestOnlyAMovementMovesTheCashBalance goes red (the direct update is accepted). Quote the output, then restore.
    • Drop WITH CHECK from float_movement’s policy. The repo’s RLS guard goes red. Quote it, then restore.
  • Step 7: Commit.

Terminal window
git add db/migrations/00150_pettycash_float.sql internal/kernel/db/retention_test.go internal/gen/sqlc \
internal/platform/pettycash
git 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, then make 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 StatementBegin
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 Down
DROP 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 Down
DROP 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’s amendable loop requires table-level UPDATE, and its appendOnly loop 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 nopPoster is registered with posting.ResetForTest(); posting.Register(nopPoster{}); t.Cleanup(posting.ResetForTest).
    • A test-only graph pc_schema_probe is published with workflow.PublishGraph: draft(draft) → recorded(accrual, control petty_cash_ap) → paid(payout, terminal), with reporter gates throughout, plus recorded → voided(cancelled, reason, reverses).
    • Helpers probeInstance(t, e) (Start in its own tx) and probeMove(t, e, inst, from, to) (Transition in its own tx) are local to this file.
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_positive
func TestPettyCashDocumentsCannotBeDeleted(t *testing.T) // DELETE on petty_cash_spend, float_topup -> 42501 after PRESENCE of UPDATE revision
  • Step 5: Run. Run make generate, then go test ./internal/platform/pettycash/... ./internal/kernel/db/ -count=1 and expect PASS.

  • Step 6: Sabotage. Remove receipt_document_id from the draft trigger’s column list. TestASpendsRecordFreezesOnceRecorded goes red. Quote the output, then restore.

  • Step 7: Commit.

Terminal window
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/pettycash
git 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 (add UseRules)
  • 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() (in pettycashtest only).

  • Produces:

    const (
    KindSpend = "petty_cash_spend"
    KindTopUp = "float_topup"
    KindCloseout = "float_closeout" // Ruling PC3
    )
    func SpendGraph(ownerSeat uuid.UUID) workflow.Graph
    func TopUpGraph(ownerSeat uuid.UUID) workflow.Graph
    func 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.PostingRule
    func 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-out
    From, To string
    Actor uuid.UUID
    Reason 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 || Cancelled
    package 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 grants
func 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 <- seat
func 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 not
func 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 are en and zh, 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.go as in Interfaces. Implement instance.go:

    • mayTake is Plan 5’s mayTake (internal/platform/expenses/transition.go) copied, with the same unroutable-submission exception.
    • actsOn returns true if any AvailableTransitions entry is Allowed, or if any Decisions row has Actor or AuthorityMember equal to the member.
    • mayReview loads the instance and definition. It returns true if some Available entry whose To state has semantic posting.Rejected is Allowed for the actor, and the actor is not inst.Reporter.
    • export_instance_test.go exports MayTake, ActsOn and MayReview for the tests.

    Add UseRules to pettycashtest.

  • Step 5: Run. go test ./internal/platform/pettycash/... -count=1 → PASS. Then run go test ./internal/platform/workflow/... ./internal/platform/expenses/... -count=1. Linking pettycash changes what step 60 seeds in any test binary that imports it. Expect no change yet, because nothing else imports pettycash until Task 14. Say so in the report.

  • Step 6: Sabotage. In SpendGraph, set ReversesAccrual false on disputed → voided. TestEveryPettyCashGraphPassesTheValidator goes red with rule 5’s reversal_missing. Quote it, then restore.

  • Step 7: Commit.

Terminal window
git add internal/platform/pettycash
git 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 (add Float)
  • 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 CHECK account_owner_module_known admits 'pettycash'), chart.TypeAsset; company.Get (base currency, as expenses.CreateClaim reads 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.2
    OwnerModule = "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.UUID
    Code string
    HolderMemberID uuid.UUID
    HolderName string // the holder party's legal_name
    AccountID uuid.UUID
    Currency string
    CashBalance, Committed money.Amount
    Status string
    RefundProofDocumentID *uuid.UUID
    OpenedOn time.Time
    ClosedOn *time.Time
    OpenedBy uuid.UUID
    CreatedAt, 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.UUID
    Code string
    HolderMemberID uuid.UUID
    HolderName string
    AccountID uuid.UUID
    OpenedOn time.Time
    ClosedOn *time.Time
    }
    func (f Float) Ref() FloatRef
    type 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 first
    func ListFloats(ctx context.Context, tx pgx.Tx) ([]FloatRef, error) // Plan 9 D2
    func GetFloat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (FloatRef, error) // Plan 9 D2
    func 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.UUID
    Available, 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-0002
func TestAFloatHolderMustBeAnActiveMember(t *testing.T) // admin: organization_member.active = false -> ErrHolderInactive; PRESENCE first with an active member
func 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 true

text_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:

    1. Read the org.
    2. 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, or active = false, gives ErrHolderInactive.
    3. If liveFloatOf(holder, false) finds one, return ErrHolderHasLiveFloat.
    4. Call chart.GetAccountByCode(FloatHeaderCode). chart.ErrNotFound, IsPostable, !IsActive or Type != asset all give ErrNoFloatHeader.
    5. Read the base currency from company.Get.
    6. Allocate the code: numbering.Allocate(ctx, tx, "petty_cash_float", year), then code := numbering.Format("PF", year, n).
    7. 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}).
    8. INSERT petty_cash_float. Map 23505 on petty_cash_float_one_live_per_holder to ErrHolderHasLiveFloat (the race).
    9. Write audit.Write and activity.Record.

    Every read joins party for HolderName.

    CloseEmptyFloat: LockFloat. A closing float gives ErrFloatNotOpen; a closed one gives ErrFloatClosed. A float with a non-zero balance gives ErrFloatNotEmpty. Then run UPDATE … SET status='closed', closed_on=$today, updated_at=$now, and write audit close and activity closed.

    holderParty: SELECT party_id FROM organization_member WHERE org_id=$1 AND id=$2.

    Add pettycashtest.Float(t, holder) pettycash.Float (opened by Owner).

  • Step 4: Run. PASS.

  • Step 5: Sabotage. Drop OwnerModule from the AccountInput. TestAManualEntryCannotTouchAFloatAccount goes red (the manual entry posts). Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git commit -m "Open petty-cash floats as module-owned cash accounts under the 1530 header
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

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.UUID
    OccurredAt time.Time
    Amount money.Amount // signed: + into the tin, - out of it
    Kind, SourceType string
    SourceID uuid.UUID
    JournalEntryID uuid.UUID
    ReversesMovementID *uuid.UUID
    RecordedBy uuid.UUID
    }
    type movementInput struct {
    Float Float // as locked by the caller, and as it stands NOW (re-read after any committed change)
    Kind string
    SourceType string
    SourceID uuid.UUID
    JournalEntryID uuid.UUID
    Amount money.Amount
    Reverses *uuid.UUID
    Actor 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.go exports WriteMovement, MovementInput (an alias of the unexported struct), PostedEntry, NetOn and ForwardMovement.

    • Journal entries for the FK are posted directly with ledger.Post, using Semantic: "payout", SourceKind: KindTopUp, a SourceID, and the InstanceID of a float_topup instance started in an earlier transaction.
    • Their lines are Dr the float’s account and Cr 1520. ledger.Post allows a non-manual line on an owned account.
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) -> ErrFloatClosed
func 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:

    1. in.Float.Status == StatusClosed gives ErrFloatClosed.
    2. If in.Amount.IsNegative() and in.Float.Available().Add(in.Amount).IsNegative(), return &InsufficientFloatError{FloatID, Available: in.Float.Available(), Needed: in.Amount.Neg()}.
    3. INSERT … RETURNING with occurred_at = c.Now().UTC(). A 23514 naming petty_cash_float_available_nonnegative maps to *InsufficientFloatError; one naming petty_cash_float_closed_shape maps to ErrFloatClosed.
    4. f := ReadFloat(in.Float.ID).
    5. 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 with Semantic == semantic, else ErrNoPostedEntry, then ledger.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. TestAMovementBeyondAvailableIsRefusedTyped stays 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.

Terminal window
git add internal/platform/pettycash
git 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.UUID
    Reference string
    FloatID, RequestedBy, InstanceID uuid.UUID
    Purpose string
    Amount money.Amount
    Currency, Reason string
    Revision int
    State string
    Semantic posting.Semantic
    CreatedAt, UpdatedAt time.Time
    }
    type TopUpInput struct {
    FloatID, Actor uuid.UUID
    Amount money.Amount
    Reason string
    }
    func CreateTopUp(ctx context.Context, tx pgx.Tx, c clock.Clock, in TopUpInput) (TopUp, error)
    type TopUpPatch struct {
    TopUpID, Actor uuid.UUID
    Revision int
    Amount *money.Amount
    Reason *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 error
    type TopUpPayoutRule struct{} // Kind() KindTopUp; Semantic() posting.Payout
    func (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 in topup_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 true

topup_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:

    1. f := ReadFloat. in.Actor != f.HolderMemberID gives ErrNotHolder. A closing float gives ErrFloatNotOpen; a closed one gives ErrFloatClosed.
    2. Validate: amount must be positive (*FieldError{amount, positive}), and the reason goes through checkRecordText("reason", s, 500).
    3. Allocate: numbering.Allocate(ctx, tx, "float_topup", year), prefix PT.
    4. id := uuid.New(), then workflow.Start(KindTopUp, id, f.HolderMemberID, amount).
    5. INSERT with purpose topup and currency = f.Currency.
    6. Write audit.Write create.

    UpdateTopUp:

    1. LockTopUp, then check the actor is the holder and the revision matches.
    2. A replenishment gives ErrReplenishmentFixed. A non-draft semantic gives ErrNotDraft.
    3. Validate. If the amount changed, call workflow.SetAmount.
    4. UPDATE … revision = revision + 1, then write audit update.

    Reads join workflow_instance and workflow_state for State and Semantic, as Plan 5 R40 does.

    CanReadTopUp: tu.RequestedBy == member || readAll || actsOn(…).

    TopUpPayoutRule.Build:

    1. tu := GetTopUp(src.Event.SourceID). A mismatched instance gives ErrInstanceMismatch.
    2. inst := workflow.Get. tu.Amount.Cmp(inst.Amount) != 0 gives ErrAmountDrift.
    3. f := ReadFloat(tu.FloatID). f.Status != StatusOpen gives ErrFloatNotOpen, a defence behind Task 8’s pre-check.
    4. j := chart.DefaultJournal(ctx, tx, chart.JournalBank). j.DefaultAccountID == nil gives ErrNoBankAccount.
    5. party := holderParty.
    6. 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. TestPayingATopUpDebitsTheFloatAgainstTheBank goes red. Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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 (add Fund)
  • 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 calls e.UseRules(t); the float is e.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 unchanged
func 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:

    1. tu := LockTopUp(in.ID).
    2. inst, def := stateOf(tu.InstanceID). Then from, _ := def.State(in.From) and to, known := def.State(in.To).
    3. examine := known && inst.StateKey == in.From && mayTake(…, in.Actor, in.To).
    4. needFloat := known && (to.Semantic == posting.Payout || reverses(to.Semantic)). If it holds, f := LockFloat(tu.FloatID).
    5. If examine and the move enters payout: f.Status must be open (ErrFloatNotOpen, or ErrFloatClosed).
    6. If examine and reverses(to.Semantic):
      • fwd := forwardMovement(KindTopUp, tu.ID, MovementTopUp).
      • If fwd != nil: f.Status == closed gives ErrFloatClosed, and f.Available().Cmp(tu.Amount) < 0 gives *InsufficientFloatError.
    7. d := workflow.Transition(…).
    8. The Plan 5 R21 guard: if !examine && known && (to.Semantic == posting.Payout || reverses(to.Semantic)), return workflow.ErrStaleState. The engine allowed a move whose checks were skipped, so it rolls back.
    9. Entering payout: e := postedEntry(tu.InstanceID, ledger.SemanticPayout), then writeMovement{Float: f, Kind: MovementTopUp, SourceType: KindTopUp, SourceID: tu.ID, JournalEntryID: e.ID, Amount: tu.Amount, Actor: in.Actor}.
    10. If reverses(to.Semantic) && fwd != nil:
      • orig := ledger.Get(fwd.JournalEntryID).
      • Only if orig.ReversedByID != nil, write writeMovement{Kind: MovementTopUpReversal, JournalEntryID: *orig.ReversedByID, Amount: tu.Amount.Neg(), Reverses: &fwd.ID} (Ruling PC14).
    11. Write audit.Write{EntityKind: "float_topup", Action: "transition", Before: {state}, After: {state, decision_id, movement_id}}, then activity.Record (topped_up when entering payout, else transitioned).
    12. Return GetTopUp.

    Add Fund to pettycashtest.

  • Step 4: Run. PASS.

  • Step 5: Sabotage. Move step 4’s LockFloat after workflow.Transition. TestTheTopUpLocksBeforeTheFloatAndTheFloatBeforeTheInstance goes red, because the instance probe finds the instance locked. Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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 (add Receipt, 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 from expenses, 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 in chart/taxcode.go (read its Get… 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 PC7
    const (
    OutcomeQueried Outcome = "queried"
    OutcomeVerified Outcome = "verified" // the only value that opens the pay edge
    OutcomeNotReimbursable 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 *string
    EInvoiceValidatedAt *time.Time
    }
    type Spend struct {
    ID uuid.UUID
    Reference string
    FloatID, HolderMemberID, InstanceID uuid.UUID
    ExpenseDate time.Time
    Description string
    Tax TaxRecord
    Amount money.Amount
    Currency string
    ExpenseAccountID uuid.UUID
    TaxCodeID, ReceiptDocumentID *uuid.UUID
    Outcome *Outcome // nil: not yet reviewed
    ReviewedBy *uuid.UUID
    ReviewedAt *time.Time
    Revision int
    State string
    Semantic posting.Semantic
    CreatedAt, UpdatedAt time.Time
    }
    func (s Spend) Verified() bool // s.Outcome != nil && *s.Outcome == OutcomeVerified
    type SpendInput struct {
    FloatID, Actor uuid.UUID
    ExpenseDate time.Time
    Description string
    Tax TaxRecord
    Amount money.Amount
    ExpenseAccountID uuid.UUID
    TaxCodeID *uuid.UUID
    }
    func CreateSpend(ctx context.Context, tx pgx.Tx, c clock.Clock, in SpendInput) (Spend, error)
    type SpendPatch struct {
    SpendID, Actor uuid.UUID
    Revision int
    ExpenseDate *time.Time
    Description *string
    Tax *TaxRecord // replaces the whole record when set
    Amount *money.Amount
    ExpenseAccountID *uuid.UUID
    TaxCodeID **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.UUID
    Revision int
    Outcome 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 pettycashtest
    func (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 pending
    func (e *Env) DraftSpend(t *testing.T, f pettycash.Float, amount string) pettycash.Spend // 6110, "Printer paper", Today, relaxation_receipt with a reason, receipt bound
    func (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 recorded or voided before Task 11, they move its instance with workflow.Transition directly 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) -> ErrFloatNotOpen
func 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") -> ok
func TestADraftOrPaidSpendIsNotReviewable(t *testing.T) // draft -> ErrNotReviewable; Outcome("approved") -> ErrOutcomeUnknown; paid (workflow.Transition, nop poster) -> ErrNotReviewable
func 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:

    1. f := ReadFloat(in.FloatID). in.Actor != f.HolderMemberID gives ErrNotHolder. A non-open float gives ErrFloatNotOpen or ErrFloatClosed.
    2. Validate: amount positive; checkRecordText for 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 on ExpenseDate.
    3. Allocate: numbering.Allocate(ctx, tx, "petty_cash_spend", year), prefix PS.
    4. id := uuid.New(), then workflow.Start(KindSpend, id, f.HolderMemberID, amount).
    5. INSERT with currency = f.Currency, then audit create.

    usableExpenseAccount: chart.GetAccount. Type != expense gives not_expense, !IsPostable gives not_postable, and !IsActive gives inactive. Each is a *FieldError on expense_account_id.

    UpdateSpend: LockSpend, then holder, revision and draft checks, then validate. Call workflow.SetAmount when the amount changes. Then UPDATE … revision + 1 and audit update.

    BindReceipt (contract §7 order):

    1. LockSpend, then holder, revision and draft checks.
    2. doc := documents.Lock(ctx, tx, documentID). documents.ErrNotFound gives ErrReceiptNotVerified.
    3. doc.UploadedBy != s.HolderMemberID gives ErrReceiptNotYours. A status other than verified gives ErrReceiptNotVerified.
    4. bs := documents.BoundElsewhere(ctx, tx, documentID, documents.Binding{Kind: KindSpend, RecordID: s.ID}). A non-empty result gives &BoundElsewhereError{bs}.
    5. UPDATE receipt_document_id, revision + 1, then audit bind_receipt.

    ReviewSpend:

    1. LockSpend, then the revision check.
    2. An Outcome not in Outcomes gives ErrOutcomeUnknown.
    3. s.Semantic != posting.Accrual gives ErrNotReviewable.
    4. in.Actor == s.HolderMemberID gives ErrSelfReview.
    5. !mayReview(ctx, tx, c, s.InstanceID, in.Actor) gives ErrNotReviewer.
    6. 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() returns KindSpend.
    • BoundTo selects spends with receipt_document_id = $2 whose instance state semantic is not cancelled (join workflow_instance and workflow_state, each with org_id = $1), as {KindSpend, id, reference}.
    • MayRead returns true if member.Can(ctx, tx, memberID, PermissionReadAll), or if CanReadSpend(…, false) holds for any spend BoundTo returns.
  • Step 4: Run. PASS.

  • Step 5: Sabotage.

    • Drop the semantic <> 'cancelled' filter from BoundTo. TestAVoidedSpendsReceiptIsFreeAgain goes red. Quote it, then restore.
    • Remove the self-review check. TestNobodyReviewsTheirOwnSpend still refuses, because mayReview excludes the reporter. Quote that, then also make mayReview ignore the reporter, and the test goes red. Quote it, then restore both.
  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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 AP
    type SpendPayoutRule struct{} // Kind KindSpend, Semantic Payout: Dr the accrual's control / Cr the float's account
    var (
    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 call e.UseRules(t) and walk with workflow.Transition directly; the commitment and the movement are Task 11’s. The outcome is set with ReviewSpend. The spend is 45.60 on 6110 with SVT8, 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 ever
func 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 builds
func TestCannotPayWhatWasNeverAccrued(t *testing.T) // SpendPayoutRule.Build on a draft spend's instance -> ErrNotAccrued
func 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:

    1. src.Control == nil gives fmt.Errorf("pettycash: accrual: %w", chart.ErrNotFound).
    2. s := GetSpend(src.Event.SourceID). An instance mismatch gives ErrInstanceMismatch.
    3. inst := workflow.Get. s.Amount.Cmp(inst.Amount) != 0 gives ErrAmountDrift.
    4. s.ReceiptDocumentID == nil gives ErrReceiptRequired.
    5. Call usableExpenseAccount, then party := holderParty.
    6. 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.Build follows Plan 5’s PayoutRule (internal/platform/expenses/posting.go) step for step:

    1. s; the instance check.
    2. open := ledger.Unreversed(ctx, tx, s.InstanceID). Take its one SemanticAccrual header, else ErrNotAccrued.
    3. ledger.Get for its lines. Exactly one line has Credit.IsPositive(), else ErrAccrualShape; that line’s account is the control.
    4. bal := ledger.SourceBalance(ctx, tx, KindSpend, s.ID, control), then amount, base := bal.Amount.Neg(), bal.Base.Neg(). amount.Cmp(s.Amount) != 0 gives ErrAmountDrift.
    5. f := ReadFloat(s.FloatID).
    6. 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. TestPayingASpendClearsPettyCashAPAgainstTheFloat goes red. Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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 (add RecordedSpend, 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.Spend
    func (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 call e.UseRules(t); f := e.Float(t, e.Staff) and e.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 entry
func 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:

    1. s := LockSpend(in.ID).
    2. inst, def := stateOf(s.InstanceID); read from, to and known.
    3. examine := known && inst.StateKey == in.From && mayTake(…).
    4. checked := known && (to.Semantic == posting.Accrual || to.Semantic == posting.Payout || reverses(to.Semantic)). If it holds, f := LockFloat(s.FloatID).
    5. If examine, run the checks for the edge:
      • → accrual: f must be open. The receipt must be present (ErrReceiptRequired) and documents.Get verified (ErrReceiptNotVerified). The tax record must be complete: checkTaxRecord(s.Tax, true), so a missing basis gives *FieldError{document_basis, required} (Ruling PC16). Call usableExpenseAccount. f.Available().Cmp(s.Amount) < 0 gives *InsufficientFloatError.
      • → payout: !s.Verified() gives ErrNotVerified. f must be open.
      • accrual → rejected: s.Outcome == nil || *s.Outcome != OutcomeNotReimbursable (exact string comparison) gives ErrOutcomeMismatch.
      • → rejected/cancelled from a non-accrual state: fwd := forwardMovement(KindSpend, s.ID, MovementSpendPayout). If fwd != nil && f.Status == StatusClosed, return ErrFloatClosed (PC33).
    6. d := workflow.Transition(…).
    7. If !examine && checked, return workflow.ErrStaleState.
    8. 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 -). Then f2 := ReadFloat. Then e := postedEntry(s.InstanceID, ledger.SemanticPayout). Then writeMovement{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). If orig.ReversedByID != nil, writeMovement{Kind: MovementSpendPayoutReversal, JournalEntryID: *orig.ReversedByID, Amount: s.Amount, Reverses: &fwd.ID}.
    9. Write audit.Write{EntityKind: "petty_cash_spend", Action: "transition", Before: {state}, After: {state, decision_id, committed_delta, movement_id}}, then activity.Record (recorded, paid, voided or transitioned).

    Add the three pettycashtest helpers.

  • Step 4: Run. PASS.

  • Step 5: Sabotage.

    • Swap the payout order (movement, then release). TestPayingTheWholeFloatWorks goes red with *InsufficientFloatError. Quote it, then restore.
    • Change the pay gate to s.Outcome != nil (a truthiness check). TestOnlyAVerifiedSpendIsPaid goes red, because a “not_reimbursable” spend pays. Quote it, then restore.
  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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 and Movement (6); KindTopUp, PurposeReplenishment, GetTopUp, ErrReplenishmentFixed (7); TransitionTopUp (8); workflow.Start; numbering; tests: expenses.Transition and the Cashier claim world of Task 14.

  • Produces:

    type ReplenishmentLine struct {
    ID, TopUpID, MovementID uuid.UUID
    MovementKind string // spend_payout, claim_settlement, or a *_reversal (negative line)
    SourceType string // petty_cash_spend | expense_claim
    SourceID uuid.UUID
    Amount money.Amount // + restores an outflow; - returns a late credit-back (Ruling PC20)
    CreatedAt time.Time
    ReleasedAt *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 call e.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 -> ErrNothingToReplenish
func TestNothingToReplenishOnAFreshFloat(t *testing.T) // -> ErrNothingToReplenish; no top-up row, no instance
func 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 released
func 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 written
func TestAReplenishmentsAmountIsFixed(t *testing.T) // UpdateTopUp{Amount} -> ErrReplenishmentFixed
func TestOnlyTheHolderRequestsAReplenishment(t *testing.T) // Owner -> ErrNotHolder
  • Step 2: Run and watch fail.

  • Step 3: Implement RequestReplenishment:

    1. f := LockFloat(floatID). Check the actor is the holder and the float is open.
    2. 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
  1. If there are no rows, or the sum is ≤ 0, return ErrNothingToReplenish and write nothing.
  2. Allocate a PT reference.
  3. workflow.Start(KindTopUp, id, f.HolderMemberID, sum).
  4. INSERT float_topup with purpose = 'replenishment' and reason fmt.Sprintf("Replenishment of %d petty-cash disbursements", positiveLines), then insert the lines. Map 23505 on float_replenishment_line_movement_once to ErrNothingToReplenish (the race).
  5. Write audit (float_topup, create, with After naming the movement ids and amounts) and activity.

In topup_transition.go:

  • Before workflow.Transition, when examine holds and the move enters payout with tu.Purpose == PurposeReplenishment: no positive active line’s movement may have a reversing movement, else ErrReplenishmentStale. Use one EXISTS query.

  • 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. TestALateReversalReducesTheNextReplenishment goes red (Amount 50, not 20). Quote it, then restore.
    • Drop s.reviewer_outcome = 'verified'. TestOnlyApprovedUnreversedDisbursementsCount stays green, because every paid spend is verified by the pay gate. Quote that honestly. Then also disable the pay gate (Task 11’s ErrNotVerified check), and the test goes red. Quote it, then restore both.
  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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.UUID
    Reference string
    FloatID, RequestedBy, InstanceID uuid.UUID
    Amount money.Amount
    Currency string
    ProofDocumentID uuid.UUID
    State string
    Semantic posting.Semantic
    CreatedAt 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 account
    var (
    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 call e.UseRules(t) on Staff’s float, with Fund 100 and PaidSpend 30 (cash 70). The proof is e.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 -> ErrFloatHasCommitments
func 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:

    1. f := LockFloat. Check the actor is the holder. The float must be open: closing gives ErrFloatNotOpen, closed gives ErrFloatClosed.
    2. !f.Committed.IsZero() gives ErrFloatHasCommitments. !f.CashBalance.IsPositive() gives ErrFloatEmpty.
    3. doc := documents.Lock(proof). The uploader must be the holder (ErrProofNotYours) and the document verified (ErrProofNotVerified).
    4. bs := documents.BoundElsewhere(ctx, tx, proof, documents.Binding{Kind: "float", RecordID: f.ID}). A non-empty result gives *BoundElsewhereError.
    5. Allocate: numbering.Allocate(ctx, tx, "float_closeout", year), prefix PX.
    6. workflow.Start(KindCloseout, id, f.HolderMemberID, f.CashBalance).
    7. INSERT float_closeout, then UPDATE petty_cash_float SET status = 'closing', refund_proof_document_id = $3, updated_at = $4.
    8. Audit the close-out (create) and the float (closing), then write activity.

    TransitionCloseout:

    1. co := GetCloseout; inst, def := stateOf; f := LockFloat(co.FloatID); compute examine.
    2. If examine and the move enters payout: f.Status != closing gives ErrFloatNotOpen, and f.CashBalance.Cmp(co.Amount) != 0 || !f.Committed.IsZero() gives ErrCloseoutStale.
    3. workflow.Transition, then the stale guard for payout.
    4. Entering payout: e := postedEntry(payout), then writeMovement{Kind: MovementCloseout, SourceType: KindCloseout, SourceID: co.ID, JournalEntryID: e.ID, Amount: co.Amount.Neg()}. Then UPDATE … SET status = 'closed', closed_on = $today. The order matters: petty_cash_float_closed_shape needs the float empty.
    5. Entering cancelled: UPDATE … SET status = 'open', refund_proof_document_id = NULL.
    6. Audit and activity (closed or transitioned).

    CloseoutPayoutRule.Build:

    1. co, then the instance and amount checks.
    2. f := ReadFloat. f.CashBalance.Cmp(co.Amount) != 0 gives ErrCloseoutStale.
    3. j := chart.DefaultJournal(bank). A nil default account gives ErrNoBankAccount.
    4. 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:

    • BoundTo selects close-outs with proof_document_id = $2 whose instance semantic is not cancelled, as {Kind: "float", RecordID: float_id, Reference: the float's code} (join petty_cash_float with org_id = $1).
    • MayRead returns true for member.Can(read_all), member.Can(PermissionManage), or CanReadCloseout of any such close-out.

    CanReadCloseout: requester, readAll, or actsOn.

  • Step 4: Run. PASS.

  • Step 5: Sabotage. Swap step 4’s order (status first, then movement). TestClosingOutBanksTheRemainder goes red with 23514 petty_cash_float_closed_shape. Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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()’s ErrChannelPettyCash branch, the settlement’s CreditAccountID (the float’s account for petty cash), and the LinkSettlement call after the insert;
    • internal/platform/expenses/posting.go: PayoutRule.Build’s ErrPettyCashDeferred branch (now a check that s.CreditAccountID is the payer’s float account), the cash-journal choice, and the removal of ErrPettyCashDeferred;
    • internal/platform/expenses/errors.go: remove ErrChannelPettyCash;
    • 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: the petty cash row of TestPlan5sOwnChannelChecks.
  • Modify (Ruling PC2): internal/modules/expenses/errors.go (the one case, 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, expensestest helpers in payout_test.go/accrual_test.go (accruedClaim, claimNow, payoutSource, insertSettlement, sourceBalance, accountID).

  • Produces (package pettycash, the whole API expenses may 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.Kind
    SourceID, InstanceID, SettlementID uuid.UUID
    Actor 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 Float
    Movement 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, ErrChannelPettyCash and ErrPettyCashDeferred are deleted. A float refusal reaches the claim’s caller as the pettycash error itself, wrapped with %w.

  • Step 1: Write the failing tests.

internal/platform/expenses/pettycash_settlement_test.go (package expenses_test):

  • e := pettycashtest.New(t), with e.UseRules(t) called after any Plan 5 helper that registers its own Poster.
  • f := e.Float(t, e.Cashier), then e.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 accruedClaim pattern. category_verified → completed is taken by Cashier, through the Owner’s expense_claim delegation, with PayoutChannelID = 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 unchanged
func 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 ActorMemberID with pettycash.OpenFloat. No funding is needed; Build only checks the account. Insert the petty-cash settlement row with credit_account_id = the float’s account, as settle() now writes it. Build returns a Draft whose credit line is the float’s account, not cash (1510), and whose JournalType is chart.JournalCash.
  • ABSENCE: a petty-cash row whose credit_account_id is 1510, which is what Plan 5’s placeholder would have stored, gives errors.Is(err, pettycash.ErrSettlementPayerMismatch) and no Draft. The same with a payer holding no float gives pettycash.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=1 and expect FAIL.

  • Step 3: Implement pettycash/settlement.go:

    • ReserveSettlement:
      1. f := liveFloatOf(ctx, tx, payer, true), or ErrNoOpenFloat.
      2. f.Status == closing gives ErrFloatNotOpen.
      3. currency != f.Currency gives ErrFloatCurrency.
      4. f.Available().Cmp(amount) < 0 gives *InsufficientFloatError.
    • SettlementAccount: liveFloatOf(payer, false). It must be open, else ErrNoOpenFloat or ErrFloatNotOpen. Return its AccountID.
    • LinkSettlement: INSERT float_claim_settlement (org_id, settlement_id, float_id, created_at).
    • RecordSettlement:
      1. in.SourceKind != SourceClaim returns an error.
      2. SELECT float_id FROM float_claim_settlement WHERE org_id = $1 AND settlement_id = $2. No row means return nil, nil.
      3. f := LockFloat(floatID). This is a no-op re-lock, because ReserveSettlement took it earlier in this transaction.
      4. f.HolderMemberID != in.Actor gives ErrSettlementPayerMismatch.
      5. e := postedEntry(in.InstanceID, ledger.SemanticPayout). Then amt := netOn(e, f.AccountID), which is negative; zero gives ErrSettlementPayerMismatch.
      6. 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 means nil, nil. Then f := LockFloat(fwd.FloatID); a closed float gives ErrFloatClosed.
    • RecordSettlementReversal: orig := ledger.Get(h.Movement.JournalEntryID). If ReversedByID == nil, return nil, nil. Otherwise f := ReadFloat(h.Float.ID), then writeMovement{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’s CreditAccountID (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 the ErrPettyCashDeferred var and its comment. In Build, replace the branch and the two s.CreditAccountID uses:
// 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: delete ErrChannelPettyCash and 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 the ErrChannelPettyCash/ErrPettyCashDeferred case 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=1 and 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 leave credit = ch.CreditAccountID. TestAClaimSettledFromTheTinDebitsThePayersFloat goes red, because Build’s check refuses with ErrSettlementPayerMismatch. Then also delete Build’s equality check, and TestAPettyCashSettlementIsNeverCreditedToTheChannelAccount goes red, because 1510 is credited. Quote both, then restore.
    • Delete step 4b. TestAnUpheldClaimDisputeCreditsTheFloatBack goes red (cash stays 332). Quote it, then restore.
  • Step 7: Commit.

Terminal window
git add internal/platform/pettycash internal/platform/expenses internal/modules/expenses
git 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 float
    CheckFloatCommitment = "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 call e.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, then RESET 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.OrgFromContext and names it as org_id = $1 on every table. It returns one Finding{Check, Ref, Detail} per offending row, as Plan 5’s reconcile.go does. The queries:

-- float_integrity
SELECT 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_balance
HAVING f.cash_balance <> coalesce(sum(m.amount), 0)
-- float_commitment
SELECT 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.committed
HAVING f.committed <> coalesce(sum(s.amount) FILTER (WHERE st.semantic = 'accrual'), 0)
-- float_ledger, part 1
SELECT 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_balance
HAVING f.cash_balance <> coalesce(sum(l.base_debit - l.base_credit), 0)
-- float_ledger, part 2
SELECT 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.amount
HAVING 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_commitment count semantic IN ('accrual','payout'). TestTheFloatChecksPassOnRealFloats goes red, because the paid spend now counts. Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add internal/platform/pettycash
git 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), then make generate;
    • api/openapi.yaml (append paths and schemas), then make generate;
    • internal/app/app.go, internal/app/routes_test.go (composedModules);
    • cmd/api/main.go, cmd/migrate/main.go, cmd/orgadmin/main.go, cmd/worker/main.go;
    • cmd/migrate/wiring_test.go, cmd/orgadmin/wiring_test.go, cmd/worker/wiring_test.go.
  • Test: internal/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 table
    type Replay struct {
    Status int
    Body json.RawMessage
    }
    func BeginIdempotent(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, key, requestHash string) (*Replay, error)
    func FinishIdempotent(ctx context.Context, tx pgx.Tx, member uuid.UUID, key string, status int, body []byte) error
    var ErrIdempotencyKeyReused error
    package pettycash // internal/modules/pettycash
    const (
    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.DB
    Clock clock.Clock
    Logger *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 are TestAKeyReplaysItsStoredResponse, TestAKeyWithADifferentRequestIsRefused, TestAFailedRequestGivesItsKeyBack and TestConcurrentSameKeyRequestsSerialize (10 s contexts). A fifth, TestPettyCashKeysAreNotExpenseKeys, begins the same (member, key) in expenses.BeginIdempotent and then in pettycash.BeginIdempotent; both return nil (fresh), because the namespaces are separate (contract §3).

    • routes_test.go: TestEveryRouteRequiresItsExactPermission, in the shape of internal/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().Lookup finds 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’s TestTheFloatChecksPassOnRealFloats proves it through reconcile.Default(), so no boot test repeats it.
      • TestLinkingRegistersTheBinders: a Staff receipt bound to a spend is reported by documents.BoundElsewhere as petty_cash_spend.
    • float_handlers_test.go: the env pattern of internal/modules/expenses/reference_handlers_test.go, copied into this package as env, newEnv, grant, mint, do and decode. Its members:

      • admin = Owner with float.manage, read, read_all, transition, spend.review;
      • holder = Staff with float.disburse, read, transition, and documents.document.upload (the base’s upload permission; Ruling PC23);
      • outsider = Cashier with read only.

      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 200
func TestListingEveryFloatNeedsReadAllOrManage(t *testing.T) // ?scope=all as holder -> 403; as admin -> both floats; ?scope=mine as holder -> one
func 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 header
func TestASecondLiveFloatIs409(t *testing.T)
  • Step 2: Run and watch fail.

  • Step 3: Implement.

    • idempotency.go: copy internal/platform/expenses/idempotency.go, with the table pettycash_idempotency_key and errors prefixed pettycash:.

    • permissions.go: init() registers the six keys. member.RegisterPermission of the already-listed pettycash.float.disburse gets the description “Hold a petty-cash float: record spends, attach receipts, request top-ups, replenishments and close-outs”. transition gets “Use the petty-cash transition endpoints; authority to decide still comes from the workflow’s grants”.

    • constants.yaml: append pettycash.float.manage, pettycash.read, pettycash.read_all, pettycash.spend.review, pettycash.transition after the last line. Leave pettycash.float.disburse where it is.

    • boot.go: init() runs for _, r := range platformpettycash.Rules() { accounting.RegisterPostingRule(r) }. The graphs, binders and checks register from the platform package’s own init(), which this import links.

    • http.go: copy the request plumbing of internal/modules/expenses/http.go: decodeStrict, parseID, parseDate, actor, ifMatch, setETag, idempotencyKey, requestHash, and the permissionLoader. A module never imports another module.

    • errors.go mapErr (Tasks 17–18 extend it):

      Error Status Notes
      ErrFloatNotFound 404
      ErrNoFloatHeader, ErrHolderHasLiveFloat, ErrFloatNotOpen, ErrFloatClosed, ErrFloatNotEmpty 409 each with its sentence
      ErrHolderInactive 422 names holder_member_id
      *FieldError 422 its Error() text
      *RevisionMismatchError 409 “the record is at revision N; reload and retry”
      ErrIdempotencyKeyReused 422
    • float_handlers.go: one InTx per request. Visibility is CanReadFloat(f, caller, readAll || manage), else 404.

    • Composition:

      • app.Deps.PettyCash pettycash.Deps; app.Router calls pettycash.Register(api, deps.PettyCash).
      • composedModules["pettycash"] lists the six constants.
      • cmd/api builds pettycash.Deps{DB: database, Clock: clock.System(), Logger: logger}.
      • cmd/migrate, cmd/orgadmin and cmd/worker blank-import internal/modules/pettycash. The worker runs no transition, but every binary that links expenses, which now links pettycash, must also link its rules (Plan 4 R31).
      • The three wiring_test.go files gain the module in wantModules and pettycash.read and pettycash.transition in wantPermissions, as the file’s own note instructs. No step key is added (Ruling PC27).
    • api/openapi.yaml: append the five operations, and the schemas PettyCashFloat, PettyCashFloatInput, FloatStatus (string enum open, closing, closed), FloatMovement and FloatMovementKind (the seven kinds). Money fields $ref: '#/components/schemas/Money'.

  • Step 4: Run. Run make generate, then go test ./internal/modules/pettycash/ ./internal/platform/pettycash/ ./internal/app/ ./cmd/... -count=1 and expect PASS.

  • Step 5: Sabotage. Register only SpendAccrualRule in boot.go. TestLinkingTheModuleRegistersTheFourPostingRules goes red. Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add constants.yaml api/openapi.yaml internal/gen internal/kernel/vocab packages/contracts \
internal/app cmd internal/modules/pettycash internal/platform/pettycash
git 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, then make 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’s env; Staff’s float is funded through e.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 revision
func 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 -> 400
func 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 calls ifMatch. The read shape is built with AvailableTransitions (caller as actor) and Decisions. Visibility is CanReadSpend(…, member.Can(read_all)), else 404.

    mapErr gains:

    Error Status Detail
    ErrSpendNotFound 404
    ErrNotHolder, ErrSelfReview, ErrNotReviewer 403
    ErrNotDraft, ErrNotReviewable 409
    ErrReceiptNotYours, ErrReceiptNotVerified, ErrReceiptRequired, ErrOutcomeUnknown, ErrOutcomeMismatch 422 names the field
    ErrNotVerified 422 field=to rule=not_verified: only a spend the reviewer marked VERIFIED is paid
    *InsufficientFloatError 422 field=amount rule=insufficient_float: the float has <available> available; this needs <needed>
    *BoundElsewhereError 409 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.ErrStaleState 409
    workflow.ErrNoSuchEdge, ErrReasonRequired 422
    *workflow.RefusedError 403 with the code
    *workflow.SubmissionRefusedError 422
    *fiscal.PeriodNotOpenError 409
    *chart.NoControlAccountError 409 “accounting is not set up: no petty_cash_ap control account”
    *accounting.NoPostingRuleError, ledger.ErrUnbalanced 500
    *analytic.RequiredError, *analytic.TotalError 422
    a 23514 on petty_cash_spend_is_draft / petty_cash_spend_review_while_recorded 409

    The OpenAPI gains the seven operations, the schemas PettyCashSpend, PettyCashSpendInput, PettyCashSpendPatch, ReviewerOutcome (string enum queried, verified, not_reimbursable, nullable), PettyCashDocumentBasis (string enum einvoice_company, foreign_supplier, relaxation_receipt: a spend’s own set, Ruling PC16), ReviewInput, ReceiptInput and PettyCashTransitionInput, and a shared PettyCashAvailableTransition/PettyCashDecision pair.

  • Step 4: Run. Run make generate && go test ./internal/modules/pettycash/ ./internal/app/ -count=1 and expect PASS.

  • Step 5: Sabotage. Remove the ledger.MapError call in runTransition. TestACommitTimeLedgerErrorIsMapped goes red with a 500. Quote it, then restore.

  • Step 6: Commit.

Terminal window
git add api/openapi.yaml internal/gen internal/modules/pettycash
git 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, then make 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 ErrNotHolder

closeout_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. mapErr gains:

    Error Status Notes
    ErrTopUpNotFound, ErrCloseoutNotFound 404
    ErrReplenishmentFixed, ErrNothingToReplenish, ErrReplenishmentStale, ErrFloatHasCommitments, ErrFloatEmpty, ErrCloseoutStale 409 each with its sentence
    ErrProofNotYours, ErrProofNotVerified 422 names proof_document_id
    ErrNoBankAccount 409 “accounting is not set up: the default bank journal has no default account”
    a 23514 on float_topup_is_draft 409

    The OpenAPI gains the nine operations, the schemas PettyCashTopUp, PettyCashTopUpInput, PettyCashTopUpPatch, ReplenishmentLine, PettyCashCloseout and PettyCashCloseoutInput, and the enum TopUpPurpose (topup, replenishment).

  • Step 4: Run. Run make generate && go test ./internal/modules/pettycash/ ./internal/app/ -count=1 and expect PASS.

  • Step 5: Commit.

Terminal window
git add api/openapi.yaml internal/gen internal/modules/pettycash
git 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.Transition in 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 -v and 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_closeout workflow 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)”.

    Touch no other line. The float register stays Plan 9’s line.

  • 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.

Terminal window
git add internal/modules/pettycash/e2e_test.go docs/ROADMAP.md
git 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>"

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.


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) and pettycash.GetFloat(ctx, tx, id) (FloatRef, error) exist, with FloatRef{ID, Code, HolderMemberID, HolderName, AccountID, OpenedOn, ClosedOn}. HolderName is the holder party’s legal_name, and OpenedOn/ClosedOn are 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 with pettycash.GetTopUp(ctx, tx, source_id).Purpose. A replenishment’s pettycash.ReplenishmentLines name 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_integrity and float_commitment. float_ledger is a third.
    • Never print cash_balance.
    • Plan 9’s golden float fixture can be built from pettycashtest (New, UseRules, Float, Fund, PaidSpend, and RequestCloseout + TransitionCloseout).
  • Plan 9, or whoever lands River’s pruner (Plan 5 R28’s note): pettycash_idempotency_key is operational and 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, LockSettlementFloat and RecordSettlementReversal exactly as internal/platform/expenses/transition.go does, in the lock order claim → float → instance.
    • RecordSettlement accepts only SourceKind = "expense_claim" today. A new source needs a migration that re-states float_movement_kind_known, float_movement_source_matches, float_movement_sign and float_movement_reversal_shape, plus a matching Go constant. The migration must commute (contract §6): check that no higher-numbered migration redefines them.
  • 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.OnDeactivate hook that refuses while pettycash finds a float of that holder that is not closed. 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 1530 header learns it when opening a float (409). A provisioning step in 120–129 with a warning-only Check would surface it in GET /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, which pettycash may not import. Once categories live in a shared platform package, add category_id to petty_cash_spend (additive migration, composite FK) and resolve the account through it, as claims do.
    • That is also when einvoice_employee can be allowed on a spend, under the category’s employee_named_document_allowed.
  • Phase 2 e-Invoice validation: spends carry the same tax record as claim lines, through internal/platform/taxrecord. MyInvois validation of einvoice_uuid should cover petty_cash_spend as well as expense_claim_line.
  • Plan 5’s owner (Rulings PC2, PC18):
    • The case in internal/modules/expenses/errors.go that answered “arrives with Plan 8” now maps pettycash.ErrNoOpenFloat, ErrFloatNotOpen, *InsufficientFloatError and ErrFloatClosed. expenses.ErrChannelPettyCash and ErrPettyCashDeferred no longer exist.
    • For a petty-cash settlement, expense_claim_settlement.credit_account_id is the paying float’s account. Plan 8’s float_claim_settlement names the float.

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) and float_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, verified and not_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: recorded releases and never credits; spend_paid credits back through the dispute → Task 11.
    • not_reimbursable as its own terminal that reverses the accrual → Tasks 4, 10, 11.
  • §8.3
    • float_topup with 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 recorded state.
  • §5.7
    • Rule 3 under accrual_first and cash_only → Task 4’s validator tests.
    • Rule 5 past payout → the disputed → rejected flags.
  • §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.
    • expenses imports pettycash, never the reverse → Global Constraints; the compiler enforces it.
    • Plan 9 names → Task 5.
    • OwnerModule leaves 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); MayRead takes 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_paid crediting 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.go and module http.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 of TransitionTopUp (8), TransitionSpend (11) and TransitionCloseout (13), and of runTransition’s callback (17).
  • Float/FloatRef (5) are used unchanged by 6–16.
  • movementInput.Float is always the float as it stands now. Task 11 re-reads it after releasing committed.
  • writeMovement returns (Movement, Float, error) everywhere.
  • Outcome is string in Go (Outcomes is the set), TEXT with a named CHECK in SQL, and a string enum on the wire, pinned by two tests.
  • TaxRecord (Task 9) is used by CreateSpend, UpdateSpend, checkTaxRecord and TransitionSpend’s record check (Task 11). Its columns mirror 00072’s names.
  • SettlementRecord.SettlementID (Task 14) is the float_claim_settlement key written by LinkSettlement in settle().
  • Spend.Verified() is the only gate predicate (Tasks 11, 17).
  • KindSpend/KindTopUp/KindCloseout are the workflow kind, the journal_entry.source_kind, the float_movement.source_type and the numbering-scope family. SourceClaim is "expense_claim", which equals expenses.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, and ErrNoBankAccount in Task 7, used again by Task 13.
  • The rule types are TopUpPayoutRule (7), SpendAccrualRule/SpendPayoutRule (10) and CloseoutPayoutRule (13). Each is added to Rules() through its own slice.
  • The pettycashtest helpers 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).

  1. 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.

  2. PC7 reversed: reviewer outcomes are strings (queried, verified, not_reimbursable), TEXT NULL with a named CHECK, and an OpenAPI string enum. Gates compare exactly, and TestOnlyAVerifiedSpendIsPaid proves NULL, queried and not_reimbursable are refused.

  3. PC16 changed: spends capture the tax record through internal/platform/taxrecord (reconciled by Task 1), with 00072’s CHECKs mirrored. The GL expense account stays, and sharing categories is a note for later.

  4. PC18 changed: the settlement row records the float’s account as credit_account_id, and Plan 8’s float_claim_settlement links it to the float. PayoutRule checks that account is the payer’s float.

  5. Documents: uploads need documents.document.upload, which Task 16’s holder is granted. GET and complete are authenticated-only, with the binders deciding. PC30 stands.

  6. PC34: the bound-elsewhere 409 names the kind only.

  7. PC5 accepted as written.

  8. 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.


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