Petty cash by project — design
Status: proposed 2026-09-26, design only (Figma batch 2, project H). Not built.
Extends: architecture §8 (petty cash), §9 (payout channels), §10.2 (analytic axes),
§11A; Plan 8 rulings PC1–PC34; Plan 10
design §5.4; project approval P2.
Audit rows: 9.2, 9.4, 9.6, 10.2, 10.3, 11.2, 11.3.
Figma (xjx0pz8JSVeCqP1kYg3elT): page 407:17221, request 426:18791 / 430:25361 /
430:26056, return 446:20155 / 446:21158.
1. Problem
Section titled “1. Problem”The design puts petty cash under projects. There is a KPI for the “authorised project float total” across N projects, a per-project panel (name and code, used / quota, usage %, available), a request form that names a project, an expected-use date, an optional “on behalf of” employee, attachments and “save draft”, and a return form that returns part of the float, linked to a request, with a return date, a method (bank / cash / Touch ’n Go), proof and a remark. Above the records table: status chips, search, a month filter, a filter button and export.
Today a float belongs to one person (PC10: one live float per holder). It is one GL
account, 1530.<code>, with a stored cash_balance / committed locked FOR UPDATE
and a float_movement ledger. The only return is a close-out of the whole balance
(PC21). Nothing on a float knows about projects.
2. Decisions
Section titled “2. Decisions”Each is marked [choice] where two reasonable options existed. §3 lists them all.
D1 — Model: one float per (holder, project) [choice]
Section titled “D1 — Model: one float per (holder, project) [choice]”A float gains project_account_id (nullable, an analytic_account on the project
axis, found by axis key as the claims list does) and quota (nullable). PC10’s unique
index becomes one live float per (holder, project), with NULLS NOT DISTINCT, so a
holder still has at most one general (project-less) float. The project is fixed for the
float’s life, like its holder.
Why: every existing mechanism works unchanged for each float: the stored balance and its lock, the non-negative CHECKs, the movement trigger, replenishment lines, close-out and all three reconciliation checks. Each project’s cash cannot go negative by a database constraint, not by a derived sum. PC10’s own “cost if wrong” (“a holder running two tins cannot, until a settlement names its float”) foresaw this.
Rejected:
- Quotas per project on one float, with movements tagged by project. The
per-project balance becomes a derived
SUM, with no row to lock and no CHECK. A replenishment or close-out that spans projects would have to split one movement, whichfloat_movement_entry_onceforbids. - Project only as an analytic tag, with org-level quotas. A quota is a limit on one person’s float for one project, so it does not belong on the project.
Ledger: each project float gets its own account, 1530.<code> (PC11 unchanged). The
chart grows by one account per authorised (holder, project), which is ordinary sub-ledger
practice. A spend’s accrual line on a project float carries a 100 % analytic
distribution to the float’s project (§10.2, the only posting-rule change). Its cost
therefore lands in project reports, as a claim’s shares already do. Top-up, return and
close-out lines are balance-sheet lines and stay untagged.
Existing data: nothing is migrated. Existing floats are general floats
(project_account_id and quota both NULL, no cap), and every existing flow runs as it
does today. An org that moves to projects closes a general float and opens project
floats. Retagging a float that already has movements is refused, because it would
rewrite what its history meant.
Claims settled from the tin (PC18) draw only on the payer’s general float. The
liveFloatOf lookup gains project_account_id IS NULL. A project float never pays a
claim, so PC18’s “the payer’s float” stays exactly one row. [choice] The alternative
is to let the payer pick a float at settlement, which needs a new input on Plan 5’s
settle path.
D2 — Quota: set by pettycash.float.manage and audited through audit_log [choice]
Section titled “D2 — Quota: set by pettycash.float.manage and audited through audit_log [choice]”- The quota is set when an admin opens a project float for a member (that act is the “authorisation”).
- It changes through
PUT /pettycash/floats/{id}/quota {quota, reason}, which requires a reason inenorms. - Each change writes
audit.Write(float,quota_set, old → new, reason) plus an activity row.
Why: audit_log is already the tamper-evident chain (§11A.5), so a separate history
table would be a second copy of the same evidence. A quota authorises no cash by itself,
because every top-up still goes through its own approvals, so a maker-checker on the
quota would be a second approval of the same money. Lowering a quota below current use
is allowed; it only blocks further top-ups.
Quota semantics (imprest level) [choice]:
used= VERIFIED spends and settlements not yet replenished (the PC20 eligible-lines query) +committed.available=cash_balance − committed(unchanged).headroom=quota − (cash_balance + unreplenished outflows + pending top-ups).- A top-up with
purpose = 'topup'is refused whenamount > headroom(ErrOverQuota, 409, checked under the float lock). - A replenishment is exempt: it moves “used” back into cash and leaves the imprest level unchanged.
- Usage % is
used / quota, computed on the server as a ratio string. The client never divides money (F13).
Rejected: “used” as lifetime spend on the project. It can pass 100 % after one replenishment, so it cannot act as a limit, and it is a budget-vs-actual report (Phase 2’s Budgets per axis), not a float control.
D3 — Partial return: a new workflow kind float_return (cash_only)
Section titled “D3 — Partial return: a new workflow kind float_return (cash_only)”- Graph:
draft→requested→confirmed(payout, terminal);requested→cancelled. - Seeded grant:
confirmedgoes to the root seat, as for a close-out (PC6). - Row fields:
float_id, optionaltopup_id(a paid top-up on the same float; this is the “linked request”),amount,returned_on,channel_idplus a snapshot of the channel key and account,proof_document_id, andremark(en/msonly, with the named CJK CHECK). - At request it needs
amount ≤ availableand the floatopen. - At confirmation it re-checks the same condition under the float lock
(
ErrReturnStaleif a spend took the cash in the meantime). It then postsDr <channel's account> / Cr float accountand writes movementreturn(−amount, a new kind added tofloat_movement_kind_known/_source_matches/_sign). - “Return all” is the client filling in
available. It does not close the float. Once the float is empty, the admin’s existingCloseEmptyFloatcloses it.
How it differs from close-out:
| Close-out | Return | |
|---|---|---|
| Amount | Frozen at the whole balance | Any amount up to available |
committed |
Must be 0 | Unrestricted |
| Float state | Enters closing, then closed |
Stays open |
| Destination | Always the default bank | The chosen method |
| Linked request | None | May name one |
The UI’s “Return float” becomes this form. The close-out API and its in-flight instances
stay as they are. [choice] The alternative was to extend close-out with an amount,
but that breaks PC21’s frozen-amount and closing invariants.
Journal date = the confirmation date, the day finance knows the cash arrived.
returned_on is recorded on the row and printed next to it. [choice] The
alternative is to post on returned_on, which could fall in a closed period.
D4 — Return methods: reuse Plan 6 channel definitions, not profiles [choice]
Section titled “D4 — Return methods: reuse Plan 6 channel definitions, not profiles [choice]”- The method cards list active channels whose behaviour is
externaland whose journal iscashorbank(MY seeds:cash,bank_transfer,duitnow,ewallet; Touch ’n Go is anewalletprovider). - The channel’s GL binding (
credit_account_id) is the account the returned money enters, and the return snapshots it (§9.4 pattern). - The member’s payout profile does not post. The card shows its masked tail only so the holder can recognise which of their accounts the money came from.
- “Manage payout details” links to the existing profile screen.
- “Set as default” is not stored: the form pre-selects the method of the holder’s last return, a derived value with no new column.
Why: channels already carry names, ordering and the GL binding that a bespoke method list would copy.
D5 — On behalf of: a narrow, compliant version, off by default [choice]
Section titled “D5 — On behalf of: a narrow, compliant version, off by default [choice]”Plan 5 R20 (“nobody files as someone else”) stays for claims. It foresaw “delegation of filing later”. For top-up requests only:
- A new permission,
pettycash.topup.file_for_others, is seeded to no role. An administrator grants it. - The beneficiary must already hold an open float on that project. The admin who authorised the float also bounds who can receive cash, and the picker lists only those holders.
- The beneficiary is the reporter (
workflow.Start’s reporter and the holder).filed_byrecords the filer, and both appear in the audit row and on the record. - Approvals resolve from the beneficiary (their superior and unit head), never from the filer, so the filer picks no approver.
- The filer may take no transition on that instance. This is a route gate,
ErrFiledBySelf, in the pattern of PC9, because the engine’s self-approval check only knows the reporter. - The beneficiary sees the request in their list and may withdraw it.
Spends and returns stay the holder’s own, because they state what happened to cash in the holder’s hands. Rejected: leaving the feature out. This version satisfies “a claimant never picks their own approver”, and it is a separate, droppable task (T6).
D6 — Drafts: a row without a workflow instance [choice]
Section titled “D6 — Drafts: a row without a workflow instance [choice]”float_topup.instance_idandfloat_return.instance_idbecome nullable, withCHECK ((instance_id IS NULL) = (submitted_at IS NULL)).- A draft is editable. The draft guard function is replaced so that it also allows
instance_id IS NULL. - A draft appears only in its reporter’s lists, never in
to_act. POST …/{id}/submitstarts the instance in its own request, so the “Start before the first Transition” rule holds. The reference is allocated when the draft is created (PC26); it is a record number, not a statutory gapless sequence.- A draft is never deleted: no
DELETEis offered, and an abandoned draft stays a draft.
Why not a draft state in the graph: SeedDefaults leaves an already-published kind
alone, so existing orgs would never get the new state. Claims’ own draft state predates
publication.
D7 — Attachments and expected-use date
Section titled “D7 — Attachments and expected-use date”float_topup.expected_use_on DATE NULL.- A new append-only
float_topup_document (topup_id, document_id, unbound_at), the pattern of replenishment lines. A column-levelUPDATE (unbound_at)is allowed while the top-up is a draft. - A new binder kind
float_topup, bound underdocuments.Lock(PC31, PC34).
D8 — Records toolbar
Section titled “D8 — Records toolbar”- One list:
GET /pettycash/records?float_id&project_account_id&kind&semantic&q&month&cursor, a read-onlyUNION ALLover spends, top-ups, returns and close-outs joined to their instance. Each row is{kind, id, reference, date, description, signed amount, state key and label, semantic, project}. Each row passes the existingCanRead*(PC24). Why: three separately paginated lists cannot be merged or counted on the client. - Chips:
GET /pettycash/records/summary(same filters) returns counts by semantic (draft,pending,accrual,payout,rejected,cancelled) plus a count per kind. The “Return” chip iskind=float_return. [choice] The alternative, grouping by state key, merges different kinds’ states on their labels, which breaks when an org renames them. Grouping by semantic is whatsemanticTonealready colours. - Search:
q(trimmed, at most 100 characters) matches the reference, description or project code/name withILIKE. - Month:
month=YYYY-MMon the record’s business date. Filter: the kind and project selects. - Export: Plan 9’s export job with a new report,
float_register, the float register Plan 9 deferred (D2), in CSV or XLSX, with parameters{float_id | project_account_id, month}, built fromfloat_movementjoined tojournal_entryso it stays “ledger figures only” (Plan 9 R3). It is audited, retained and downloaded as today. [choice] The alternative, a client-side CSV of the visible rows, is unaudited, limited to one page, and exports pending requests that are not ledger figures. This also ticks the roadmap’s float-register line.
D9 — KPIs
Section titled “D9 — KPIs”GET /pettycash/summary?scope=mine|all (all needs read_all or float.manage)
returns:
- pending
{count, total}; authorised_total(Σ quota of open project floats) andproject_count;available_totalandavailable_ratio.
Every value is summed on the server (F13). This replaces the Plan 10 §5.4 cards, which said there was “no backing concept”; with D1 there now is one.
3. Every place a reasonable alternative was rejected
Section titled “3. Every place a reasonable alternative was rejected”- Floats per (holder, project), rather than quotas on one float or org-level quotas (D1).
- Claims settled from the tin use the general float only, rather than a payer-chosen float (D1).
- The quota history lives in
audit_log, rather than in a history table or behind a maker-checker (D2). - “Used” is measured against the imprest level, rather than lifetime spend (D2).
- Partial return is a new kind, rather than an extended close-out (D3).
- The return posts on the confirmation date, rather than on
returned_on(D3). - Return methods reuse channel definitions, rather than a bespoke list; “default” is derived, not stored (D4).
- On-behalf-of is built narrowly and off by default, rather than omitted (D5).
- Drafts are rows with no instance, rather than a new graph state (D6).
- Chips count by semantic, rather than by state key (D8).
- Export uses the Plan 9
float_registerjob, rather than a client CSV (D8).
4. API (additive only; api/openapi.yaml first)
Section titled “4. API (additive only; api/openapi.yaml first)”POST /pettycash/floatsgainsproject_account_id?andquota?.Floatgainsproject {id, code, name},quota,used,headroomandusage_ratio.- New:
PUT /pettycash/floats/{id}/quota. POST /pettycash/topupsgainsexpected_use_on?,document_ids[]?,on_behalf_of_member_id?anddraft?.- New:
POST /pettycash/topups/{id}/submitandPUT /pettycash/topups/{id}/documents. - New:
POST /pettycash/floats/{id}/returns,GET /pettycash/returns,GET /pettycash/returns/{id},PATCH /pettycash/returns/{id}(draft),POST …/{id}/submitandPOST …/{id}/transitions(Idempotency-Key, PC25). - New:
GET /pettycash/records,GET /pettycash/records/summaryandGET /pettycash/summary. POST /reports/exportsacceptsreport: float_register.- Permissions (append-only in
constants.yaml):pettycash.topup.file_for_others. Returns reusefloat.disburseto file andtransitionto decide.
5. Migrations (00260–00269)
Section titled “5. Migrations (00260–00269)”| # | Content | Retention list |
|---|---|---|
| 00260 | petty_cash_float.project_account_id (composite FK to analytic_account), quota (quota IS NULL OR quota > 0); new index petty_cash_float_one_live_per_holder_project NULLS NOT DISTINCT, then drop the old index |
unchanged |
| 00261 | float_topup: expected_use_on, submitted_at, nullable instance_id plus a shape CHECK, the replaced draft guard; float_topup_document |
appendOnly |
| 00262 | float_return plus its guard; float_movement gains kind return, re-adding its three CHECKs |
amendable |
| 00263 | float_topup.filed_by (composite FK to organization_member) |
unchanged |
| 00264 | report_export_report_known adds float_register |
unchanged |
All of them are additive except the index swap and the CHECK re-adds, which remove no
data. Every constraint is named without the _check suffix, and the migration-range row
{250, 269} is registered with the first one.
6. Screens
Section titled “6. Screens”- Page. Three KPI cards (D9) with the bars from row 9.2. The to-approve panel and the
by-project panel sit side by side; the by-project panel shows my project floats,
or all of them with
scope=all, with name and code, used / quota, a usage bar and available. Below them sits the records table with its chips, search, month, filter and export (D8). The Requests / Movements tabs are replaced by the one table; movements stay on the float detail. - Request dialog. The project select lists my open project floats (plus “General” if I hold one), with headroom shown under the amount. It also has the expected date, “on behalf of” (shown only with the permission), purpose, attachments, “Save draft” and “Submit”.
- Return dialog. It has the linked request (paid top-ups of the chosen float), the project (derived from the float and read-only), the amount with “Return all”, the date, method cards (D4), proof, remark, “Save draft” and “Submit”.
- Admin. The open-float dialog gains project and quota, and the float detail gains “Change quota” (with a reason).
7. Tests (presence before absence; each new guard watched failing once)
Section titled “7. Tests (presence before absence; each new guard watched failing once)”- One live float per (holder, project). A project float beside a general one is accepted; a second general float, or a second float on the same project, is refused.
- Quota. A top-up within headroom is accepted and one over it is refused. A replenishment over headroom is accepted. Lowering the quota blocks the next top-up.
- Accrual tagging. A spend on a project float accrues with a 100 % distribution to that project; a spend on a general float carries none.
- Return. It posts
Dr channel account / Cr float, writes a −amount movement, and the three reconciliation checks stay green. Confirmation after a competing spend is refused as stale. The linked request must be on the same float. - Legacy regression. A general float’s close-out and settlement from the tin behave as they do today.
- Drafts. A submitted top-up is in the approver’s
to_actand a draft is not. A draft is editable; once submitted it is not. - On behalf of. Refused without the permission. With it: reporter = beneficiary,
filed_byis set, approvals resolve from the beneficiary’s superior, and every transition by the filer is refused. - Records. Another holder’s rows are absent (after showing they exist to
read_all). The summary counts equal the list’s totals for the same filters. - Export. A
float_registerexport is audited and matches the float’s movements.
8. Tasks
Section titled “8. Tasks”| # | Task | Depends on |
|---|---|---|
| T1 | Project floats and quota: 00260, open-float and quota routes, Float fields, used / headroom, /pettycash/summary, and the settlement lookup restricted to the general float |
— |
| T2 | Accrual analytic distribution for spends on project floats | T1 |
| T3 | Top-up drafts, expected date, attachments binder and quota check (00261) | T1 |
| T4 | float_return kind: graph, rule, movement, reconciliation, channels, drafts (00262) |
T1 |
| T5 | Records list, summary and float_register export (00264) |
T3, T4 |
| T6 | On-behalf-of (00263, permission, filer gate); droppable | T3 |
| T7 | Web: page, by-project panel, records toolbar, both dialogs, admin quota; i18n in en / ms / zh |
the API of T1, T3, T4, T5 (it can start from openapi.yaml once those are declared) |
| T8 | E2E: a project float request, a partial return and an export; ROADMAP and DEPLOY_NOTES | T5, T7 |
In parallel: after T1, run T2, T3 and T4 together, since their migration numbers are
pre-assigned and they touch disjoint files except the append-only openapi.yaml. After
T3 and T4, run T5 and T6 together. T7 can start on the page against T1 and T5’s
declared contract while T3 and T4 build.
9. Not done
Section titled “9. Not done”- Approval of top-ups by the project’s approver (Plan 11
account_approvergrants onfloat_topup). It fits D1, since the float names one project, but it needs the engine to accept per-account totals from a non-claim kind. It is a follow-up. - Foreign-currency floats (PC11 is unchanged).