Skip to content

Project link — piece 4: project on petty cash spends and advance acquittals

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 petty cash spend and an advance acquittal can each be charged to one project, and that project reaches the journal’s expense line.

Architecture: One nullable project_account_id on petty_cash_spend (migration 01193) and on advance_acquittal (01194), composite FK to analytic_account. The save paths validate it exactly like an expense claim line (account active, on the project axis, analytic.Plan for the expense account’s type, analytic.CheckChargeable with two new flow names). The posting rules put a 100% share on the expense line’s Distribution; every other leg and every row without a project post exactly as before.

Tech Stack: Go (pgx, goose, gin), OpenAPI → oapi-codegen, React/TypeScript, vitest.

Spec: docs/superpowers/specs/2026-10-10-project-link-design.md (PL6, amended 2026-10-10: the project goes on the acquittal, not the advance). Parents: 2026-10-06-project-usage-design.md (PU3, PU4, PU9).

  • Rows with no project must post byte-identically to before (same lines, same amounts, same order, no Distribution). Prove it with a test that compares a pre-change fixture’s entry lines to the post-change output for an unprojected spend and acquittal.
  • Migrations 01193_petty_cash_spend_project.sql and 01194_advance_acquittal_project.sql, inside the declared range 1190–1199; nullable, no backfill, goose Down drops the constraint then the column; composite FK (org_id, project_account_id) REFERENCES analytic_account (org_id, id). Neither table gains an erase privilege (retention drift test must stay green).
  • The ledger is append-only: nothing rewrites already-posted entries; a spend/acquittal edited after posting is not allowed to change its project (follow how these tables already freeze after recording — if the existing guard trigger lists columns, extend it deliberately and test it).
  • New chargeable flow names petty_cash_spend and advance_acquittal are added to internal/platform/accounting/analytic/chargeable.go (validFlows + Flow* constants) and the chargeable_guard_test drift guard must still pass; call CheckChargeable at the same points analytic.Plan is called for these flows. ledger.Post stays unchecked (PU4).
  • api/openapi.yaml first; regenerate Go and TS; additive only; spend and acquittal responses gain project: {id, code, name} | null (reuse DocumentProject), requests gain project_id (uuid|null).
  • Modules never import modules. Money is never a float.
  • Anonymisation/erasure paths unaffected — verify.
  • Web: field shown only with accounting.ledger.read; an edit always sends the current project_id if the endpoint replaces the whole record (check each: PATCH vs PUT).
  • ms/zh strings need native review: rows in docs/review/{ms,zh}-review.csv; new refusal sentences in en/ms/zh common.json so the refusal-code translation drift test passes.
  • Every absence assertion is preceded by a presence assertion. Watch each new guard fail once.
  • Commit trailer Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>; no push/merge; no git stash.
  • Create db/migrations/01193_petty_cash_spend_project.sql, db/migrations/01194_advance_acquittal_project.sql.
  • Modify internal/platform/pettycash/{spend.go,spend_posting.go}, internal/modules/pettycash/{spend_handlers.go,...}; internal/platform/advances/{acquittal.go,posting_advance.go}, internal/modules/advances/{acquittal_handlers.go,view.go}; internal/platform/accounting/analytic/chargeable.go; api/openapi.yaml + generated; tests alongside.
  • Modify web: web/src/features/pettycash/* (spend dialog + drawer), the advances acquittal dialog + drawer (find under web/src/features/ — advances may live in claims/ or its own folder; grep acquit), locales, review CSVs, docs/ROADMAP.md.

Task 1: Petty cash spend — column, validation, posting, API

Section titled “Task 1: Petty cash spend — column, validation, posting, API”

Interfaces:

  • Produces: Spend.ProjectAccountID *uuid.UUID, Spend.Project *ProjectRef{ID uuid.UUID; Code, Name string}; SpendInput.ProjectAccountID, SpendPatch.ProjectAccountID (patch semantics: follow how other optional fields clear — read UpdateSpend); flow constant analytic.FlowPettyCashSpend = "petty_cash_spend"; response project, request project_id.

  • Step 1: Failing tests. Platform: create a spend with project A → returned with Project.Code; record and post (use the e2e helpers in internal/modules/pettycash/e2e_test.go / internal/platform/pettycash/*_test.go) → the posted expense line has distribution [A 100%], the float/control leg has none (assert A first); unprojected spend posts a line list equal to the pre-change expectation (copy the exact expected lines from the existing posting test); project on another axis, another org, retired → refused with the same shares_* / project_* rules expense claims use (reuse their rule names); a project chargeable only by expense_claim → not_chargeable; editing the project after recording is refused. Handler: POST/PATCH round trip.

  • Step 2: go test ./internal/platform/pettycash/ ./internal/modules/pettycash/ -count=1 → FAIL.

  • Step 3: Implement migration; scan/insert/update with the new column; a checkProject helper mirroring internal/platform/expenses/line.go’s share validation (analytic.Plan with the spend’s expense account type and amount, then analytic.CheckChargeable(ctx, tx, analytic.FlowPettyCashSpend, ids)); in SpendAccrualRule.Build set Distribution: shares on the expense ledger.Line only when a project is set; OpenAPI first, regenerate; DTO + handler fields; refusal translations.

  • Step 4: run the two packages plus ./internal/platform/accounting/analytic/ ./internal/app/ ./internal/kernel/db/ ./internal/kernel/layering/ -count=1; watch the “posted line carries the share” test fail with the Distribution line removed.

  • Step 5: commit — feat(pettycash): a spend can be charged to a project.

Task 2: Advance acquittal — column, validation, posting, API

Section titled “Task 2: Advance acquittal — column, validation, posting, API”

Interfaces: same shape as Task 1 for advance_acquittal: Acquittal.ProjectAccountID/Project, input/patch field, flow analytic.FlowAdvanceAcquittal = "advance_acquittal"; the expense line in AdvanceAccrualRule (internal/platform/advances/posting_advance.go) takes the Distribution; the control and variance legs (posting_variance.go) do not.

  • Step 1: Failing tests: record an acquittal with project A, decide/post the advance (use advancestest helpers and e2e_test.go), the accrual entry’s expense line has [A 100%] and the control line none (presence first); an unprojected acquittal’s lines unchanged; variance top-up/refund entries unaffected; refusals as in Task 1; project frozen after acquittal is decided.
  • Step 2: go test ./internal/platform/advances/ ./internal/modules/advances/ -count=1 → FAIL.
  • Step 3: Implement as Task 1 (migration 01194; where the acquittal’s expense_account_id is set at decision time, validate then as well — the expense account is only known at decide; analytic.Plan needs its type — so validate the project at BOTH save (against the category’s account when known) and decide, and let the decide-time check refuse with the same rule names; document this in the report).
  • Step 4: the two packages + analytic + app + kernel/db + layering; chargeable guard drift test; anonymise/erasure tests for both modules.
  • Step 5: commit — feat(advances): an acquittal can be charged to a project.

Task 3: Web — spend and acquittal forms, drawers, roadmap

Section titled “Task 3: Web — spend and acquittal forms, drawers, roadmap”

Files: the petty cash spend dialog and drawer under web/src/features/pettycash/, the advance acquittal dialog and drawer (grep acquit under web/src/features), their tests, locales, review CSVs, docs/ROADMAP.md.

  • Step 1: Failing tests: each form shows a Project field with accounting.ledger.read (presence), options filtered by flow with useProjectOptions(enabled, 'petty_cash_spend' | 'advance_acquittal') (extend the web flow union if typed), choosing sends project_id, “No project” sends null, the drawer shows CODE · Name; hidden without the permission and an unchanged save still sends the current project_id.
  • Step 2: vitest on those folders → FAIL.
  • Step 3: Implement reusing the single-value project select built for the opportunity form (web/src/features/crm/OpportunityDrawer.tsx’s ProjectSelect: extract to web/src/features/projects/ only if both new forms and CRM can share it without churn; otherwise copy the 20 lines and say so).
  • Step 4: full web suite once, typecheck, lint; full go test ./... -p 1 once (known unrelated failure: TestEveryTenantQueryCarriesAnExplicitOrgPredicate from main’s groupstd.go/counterparty.go; Docker-contention flakes pass alone).
  • Step 5: tick piece 4 in ROADMAP honestly (browser check, remaining limits); commit — feat(web): project on petty cash spends and advance acquittals; roadmap.
  • PL6 (amended) → Tasks 1–3. The advance itself carries no project by design; ROADMAP must say so.
  • Names consistent: project_account_id (DB/Go), project_id (request), project (response), flows petty_cash_spend / advance_acquittal.
  • Highest-risk item is “unprojected rows post byte-identically” — both backend tasks carry an explicit test for it.