Skip to content

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.

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.

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, which float_movement_entry_once forbids.
  • 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 in en or ms.
  • 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 when amount > 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: confirmed goes to the root seat, as for a close-out (PC6).
  • Row fields: float_id, optional topup_id (a paid top-up on the same float; this is the “linked request”), amount, returned_on, channel_id plus a snapshot of the channel key and account, proof_document_id, and remark (en / ms only, with the named CJK CHECK).
  • At request it needs amount ≤ available and the float open.
  • At confirmation it re-checks the same condition under the float lock (ErrReturnStale if a spend took the cash in the meantime). It then posts Dr <channel's account> / Cr float account and writes movement return (−amount, a new kind added to float_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 existing CloseEmptyFloat closes 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 external and whose journal is cash or bank (MY seeds: cash, bank_transfer, duitnow, ewallet; Touch ’n Go is an ewallet provider).
  • 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:

  1. A new permission, pettycash.topup.file_for_others, is seeded to no role. An administrator grants it.
  2. 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.
  3. The beneficiary is the reporter (workflow.Start’s reporter and the holder). filed_by records the filer, and both appear in the audit row and on the record.
  4. Approvals resolve from the beneficiary (their superior and unit head), never from the filer, so the filer picks no approver.
  5. 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.
  6. 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_id and float_return.instance_id become nullable, with CHECK ((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}/submit starts 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 DELETE is 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.

  • 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-level UPDATE (unbound_at) is allowed while the top-up is a draft.
  • A new binder kind float_topup, bound under documents.Lock (PC31, PC34).
  • One list: GET /pettycash/records?float_id&project_account_id&kind&semantic&q&month&cursor, a read-only UNION ALL over 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 existing CanRead* (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 is kind=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 what semanticTone already colours.
  • Search: q (trimmed, at most 100 characters) matches the reference, description or project code/name with ILIKE.
  • Month: month=YYYY-MM on 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 from float_movement joined to journal_entry so 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.

GET /pettycash/summary?scope=mine|all (all needs read_all or float.manage) returns:

  • pending {count, total};
  • authorised_total (Σ quota of open project floats) and project_count;
  • available_total and available_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”
  1. Floats per (holder, project), rather than quotas on one float or org-level quotas (D1).
  2. Claims settled from the tin use the general float only, rather than a payer-chosen float (D1).
  3. The quota history lives in audit_log, rather than in a history table or behind a maker-checker (D2).
  4. “Used” is measured against the imprest level, rather than lifetime spend (D2).
  5. Partial return is a new kind, rather than an extended close-out (D3).
  6. The return posts on the confirmation date, rather than on returned_on (D3).
  7. Return methods reuse channel definitions, rather than a bespoke list; “default” is derived, not stored (D4).
  8. On-behalf-of is built narrowly and off by default, rather than omitted (D5).
  9. Drafts are rows with no instance, rather than a new graph state (D6).
  10. Chips count by semantic, rather than by state key (D8).
  11. Export uses the Plan 9 float_register job, 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/floats gains project_account_id? and quota?.
  • Float gains project {id, code, name}, quota, used, headroom and usage_ratio.
  • New: PUT /pettycash/floats/{id}/quota.
  • POST /pettycash/topups gains expected_use_on?, document_ids[]?, on_behalf_of_member_id? and draft?.
  • New: POST /pettycash/topups/{id}/submit and PUT /pettycash/topups/{id}/documents.
  • New: POST /pettycash/floats/{id}/returns, GET /pettycash/returns, GET /pettycash/returns/{id}, PATCH /pettycash/returns/{id} (draft), POST …/{id}/submit and POST …/{id}/transitions (Idempotency-Key, PC25).
  • New: GET /pettycash/records, GET /pettycash/records/summary and GET /pettycash/summary.
  • POST /reports/exports accepts report: float_register.
  • Permissions (append-only in constants.yaml): pettycash.topup.file_for_others. Returns reuse float.disburse to file and transition to decide.
# 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.

  • 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_act and 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_by is 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_register export is audited and matches the float’s movements.
# 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.

  • Approval of top-ups by the project’s approver (Plan 11 account_approver grants on float_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).