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).
Global Constraints
Section titled “Global Constraints”- 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.sqland01194_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_spendandadvance_acquittalare added tointernal/platform/accounting/analytic/chargeable.go(validFlows+Flow*constants) and thechargeable_guard_testdrift guard must still pass; callCheckChargeableat the same pointsanalytic.Planis called for these flows.ledger.Poststays unchecked (PU4). api/openapi.yamlfirst; regenerate Go and TS; additive only; spend and acquittal responses gainproject: {id, code, name} | null(reuseDocumentProject), requests gainproject_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 currentproject_idif 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/zhcommon.jsonso 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; nogit stash.
File Structure
Section titled “File Structure”- 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 underweb/src/features/— advances may live inclaims/or its own folder; grepacquit), 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 — readUpdateSpend); flow constantanalytic.FlowPettyCashSpend = "petty_cash_spend"; responseproject, requestproject_id. -
Step 1: Failing tests. Platform: create a spend with project A → returned with
Project.Code; record and post (use the e2e helpers ininternal/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 sameshares_*/project_*rules expense claims use (reuse their rule names); a project chargeable only byexpense_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
checkProjecthelper mirroringinternal/platform/expenses/line.go’s share validation (analytic.Planwith the spend’s expense account type and amount, thenanalytic.CheckChargeable(ctx, tx, analytic.FlowPettyCashSpend, ids)); inSpendAccrualRule.BuildsetDistribution: shareson the expenseledger.Lineonly 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 theDistributionline 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
advancestesthelpers ande2e_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’sexpense_account_idis set at decision time, validate then as well — the expense account is only known at decide;analytic.Planneeds 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 withuseProjectOptions(enabled, 'petty_cash_spend' | 'advance_acquittal')(extend the web flow union if typed), choosing sendsproject_id, “No project” sends null, the drawer showsCODE · Name; hidden without the permission and an unchanged save still sends the currentproject_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’sProjectSelect: extract toweb/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 1once (known unrelated failure:TestEveryTenantQueryCarriesAnExplicitOrgPredicatefrommain’sgroupstd.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.
Self-review notes
Section titled “Self-review notes”- 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), flowspetty_cash_spend/advance_acquittal. - Highest-risk item is “unprojected rows post byte-identically” — both backend tasks carry an explicit test for it.