Finance — Owner Ledger, Department Books, Task Payouts
In plain terms: the money side of Staffing — a confidential ledger the platform owner keeps, a separate independent ledger each department leader keeps for their own department, a per-task payout negotiation with each assignee, and a reconciliation view that compares the owner's records against each leader's without ever syncing them.
Lives inside the Staffing backend module (apps/core-api/src/modules/staffing/finance.service.ts, task-payouts.service.ts) but is its own permission scope, deliberately not part of platform:staffing:* — see docs/iam/PERMISSIONS_CATALOG.md's 2026-07-28 entry. Money is confidential: every mutation is audit-logged but deliberately never written to TeamEvent, the /team/activity feed readable by every platform:staffing:read holder — a much wider circle than who can see finances.
How to...
Grouped to mirror the technical sections below — each page's own help icon links straight into the group relevant to it, not this whole list.
Owner ledger
Record a budget allocation or an expense — go to Finance in the left menu → Add entry (top right) → pick Type (Income/Budget/Expense), Amount, Currency, Date, Description, and optionally a Project/Department to attribute it to → Create. See field guide below.
Edit or delete a ledger entry — the entry's row → ⋯ → Edit entry or Delete entry.
Compare your records against a department leader's own book — Finance → Reconciliation tab. Only mismatches show by default; turn on Show matching to see everything, including rows that line up.
Record/edit a ledger entry — field guide
The same dialog and fields are shared by the owner ledger, a department's own book, and a project's Finances tab — only which optional attribution field shows (Project, Department, or neither) changes with where you open it from.
- Type (required) — Income (money actually received), Budget (money allocated/ reserved, not yet spent), or Expense (money actually spent). These are three distinct economic events that are never merged into one running total — Income is deliberately excluded from the Reports tab's budget-vs-expense chart, for instance.
- Amount (required) — must be a positive number; zero or negative is rejected before it even reaches the server. There's no direction/sign to set separately — whether it adds or subtracts from a total comes entirely from Type, not from the sign of the number.
- Currency (required) — AMD / USD / EUR / RUB. Every total in this module is computed per-currency with no conversion between them — recording the same kind of spend in two different currencies produces two separate totals, never one blended figure.
- Date (required) — defaults to today when creating; free to set in the past or future, and it's this date — not when the entry was actually saved — that decides which month it's grouped under in the journal and which period filters (This month, Last quarter, …) pick it up.
- Description (required) — 1–500 characters.
- Project (optional, hidden on a project's own Finances tab where it's implicit) — attributes this line to a project for the Reports tab's "where the money goes" breakdown; leave as No project for company-wide entries that aren't about a specific one.
- Department (optional, hidden on the owner ledger's per-department book and pre-filled/locked wherever the book itself already is one department) — same attribution purpose as Project, but for the Reconciliation tab's department comparison.
Department's own book
Record an entry in your own department's book (department leaders only) — open your department → the Finances tab → Add entry. This is a separate book from the owner's ledger above — see Department's own book. Same fields as the owner ledger's field guide below, minus Department (implicit).
Edit or delete an entry in your department's book — the entry's row → the pencil/trash icons (leader only).
Project finance tab
See how your own project's spending looks — open the project → its Finances tab. It's the same owner ledger, just pre-filtered to that project.
Record, edit, or delete an entry from a project's own view (platform:finance:manage) — same Add entry / ⋯ → Edit entry / Delete entry as the owner ledger, just pre-scoped to this project. Same field guide below.
Task payouts
Offer a payout to someone assigned to a task — open the task → the Task payout card → Add payout → pick the Assignee, Amount, Currency → Create (this saves as a draft, invisible to them yet) → ⋯ on that row → Send to assignee. See field guide below.
Respond to a payout offered to you — open the task; if you're the assignee, Accept / Decline buttons show directly on your payout, no menu needed.
Record an assignee's answer when they have no account — the same ⋯ menu shows "Record as accepted/declined (agreed outside the platform)" instead of waiting on their own click.
Edit or delete a payout — the payout's ⋯ → Edit entry or Delete entry. Editing the amount/currency of an already-sent offer resets it to draft — it must be re-sent. Deleting is blocked once a real payment is recorded against it, or a report is pending.
Add/edit a payout — field guide
- Assignee (required, create only — can't be changed once the payout exists) — only people already assigned to this task (Responsible or Helper) who don't already have a payout show up here; once everyone assigned has one, the Add payout button itself disappears from the card.
- Amount (required) — must be a positive number.
- Currency (required) — AMD / USD / EUR / RUB, same curated list as the ledger.
- No Description or Date field — a payout is just an amount owed to someone, not a ledger line yet; those only appear later, on the payment report (below).
- A new payout always starts as Draft — invisible to the assignee until you explicitly Send it (moves it to Proposed). Editing the Amount/Currency of an already-Proposed offer silently resets its status back to Draft — the assignee's earlier view of it is cleared and it has to be sent again before they see the change.
- Delete is blocked once any real payment has been recorded against it (
paidAmount > 0), or while a payment report is still pending confirmation — cancel the report first (see Payment confirmation, below). - A Task estimate mismatch warning appears above this list (not part of the form) if the sum of every Accepted payout in the task's own currency exceeds the task's own Price field — a soft warning only, nothing here is actually blocked by it. Payouts in a different currency than the task's own are flagged with a separate warning instead, since the two can't be compared without a conversion rate this module doesn't have.
Payment confirmation
Record that you sent someone their payout (platform:finance:manage only) — on an accepted payout, ⋯ → Report a payment → amount/date/description are pre-filled (amount = what's still owed) → submit. This doesn't touch the ledger yet — the recipient still has to confirm it arrived. See field guide below.
Confirm you received a payment — a banner appears on the task ("The owner reports sending X on Y — did it arrive?") with Confirm receipt / I didn't receive it buttons.
Record a payment outcome for a contractor with no account — the same ⋯ menu shows "Record as received/not received (agreed outside the platform)" for a manager to answer on their behalf.
Cancel a pending payment report — the payout's ⋯ → Cancel payment report (e.g. to fix a typo in the amount, without waiting for the recipient to dispute it first).
Report a payment — field guide
- Amount (required) — pre-filled to what's still owed on this payout (the amount minus whatever's already been confirmed paid), not the payout's full amount — a partial payment doesn't need hand-editing down from the total. It's capped server-side too: entering more than what's actually still owed is rejected, not silently allowed to overpay.
- Date (required) — pre-filled to today.
- Description (required) — pre-filled with a generated line ("payout to <name> for <task title>"), fully editable, up to 500 characters.
- Only one report can be pending at a time per payout — the Report a payment action itself disappears from the ⋯ menu until the current one is confirmed, disputed, or cancelled.
- Confirming (by the recipient, or a manager on their behalf for an accountless contractor) is what actually creates the real ledger
EXPENSEentry — nothing here writes to the ledger until that happens.
Dashboard banner
See what's awaiting your own response, across every task — /team/dashboard → the "My payouts" banner, when you have a proposed offer or a pending payment report. Nothing to configure — it only appears when there's something for you to answer.
The permission model
Two catalog permissions plus one data-driven rule, layered:
| Who | What they can do |
|---|---|
platform:finance:read | Read the owner ledger and every department's book (read-only for the latter) |
platform:finance:manage | Write the owner ledger; manage task payouts at the owner tier; the only role that can report/confirm a real payment |
A department's current leader (Department.leaderId, no permission string) | Read and write only their own department's book; manage payouts on tasks in their department — but cannot report or confirm real payments (see below) |
The leader check is the same data-driven pattern used throughout Staffing (department Finances/Roles tabs) — no catalog permission, just "is this department's leaderId you." platform:finance:manage is deliberately excluded from P4P Admin's default grant — finances are for P4P Owner; SUPER_ADMIN holds them too via the full-permission-catalog loop every platform permission gets.
A narrower carve-out inside task payouts (finance stage 5, 2026-07-30): creating a draft payout, sending it, and the assignee accepting/declining it is available to the owner tier or the task's department leader (TaskPayoutsService.canManage). But actually reporting or confirming a real payment (reportPayment/confirmPayment-as-manager/markPayment/cancelPaymentReport) requires platform:finance:manage specifically (hasFinanceManage/assertCanRecordPayment) — a department leader who can propose and manage an offer still cannot touch the real ledger money behind it. Found in a 2026-07-30 audit: the old "Mark as paid" action had no such gate at all.
Owner ledger (/team/finances)
In plain terms: the one place the owner tracks money in and out across the whole company — three tabs on one page, one shared time-period control above all of them.
- Records tab — the entry stream itself: a currency summary strip (income/spent/vs-previous), then the journal grouped by month. "Add entry" (
:manage) opens a dialog: type (INCOME/BUDGET/EXPENSE— three distinct economic events, never conflated; amounts are always positive, direction comes fromtype), amount, currency (AMD/USD/EUR/RUB, a curated list — adding a new one is a code change since totals are per-currency with no FX conversion), date, description, optional project/department attribution. Edit/delete per row (:manage). - Reports tab — no data of its own, same filtered set as Records: a budget-vs-expense trend chart (month/quarter/year, zoomable) and a "where the money goes" breakdown by project and by department, each row clickable to jump into Records pre-filtered to it. Income is deliberately excluded from both — it lives in the Records summary strip only.
- Reconciliation tab (labelled "Reconciliation" in the UI,
comparisoninternally) — one row per department × currency, owner's own attributed records side by side with that department's own book, budget and spent compared independently. A non-zero difference is information, not an error — the two books are never linked or synced. Matching rows are hidden by default (a "Show matching (N)" toggle reveals them) so the table only shows what actually needs attention; the tab itself only gets a badge when there's something to look at. - The period control (This month/Last month/This quarter/.../Custom range) is global to all three tabs and round-trips through the URL (
?view=&period=) — a link to "Reconciliation for last quarter" survives a reload. It's intentionally not persisted between visits: reopening the page weeks later on a stale window would present old numbers as current.
Department's own book (/team/departments/[id]/finances)
In plain terms: a department leader's private ledger for their own department — independent from the owner's, on purpose, so the Reconciliation tab above has two genuinely separate records to compare.
- Visible only to that department's current leader (read/write) and to
platform:finance:readholders (read-only, via the owner-scoped listing — the leader-only route 403s them).platform:staffing:readalone (no finance access at all) sees a "not available" message instead of the tab. - Same entry shape as the owner ledger (type/amount/currency/date/description, optional project attribution — department is implicit). Per-currency
StatTiles (Income/Budget/Spent/Remaining) above the table. - The owner reads this book on the Reconciliation tab above but can never write to it — the whole point of two independent books is comparing them; owner edits here would corrupt that.
Project finance tab (/team/projects/[id]/finances)
In plain terms: the owner ledger, pre-filtered to one project — this project's slice of company-wide finances, not a separate ledger.
- Only rendered/reachable for
platform:finance:readholders — the tab trigger is hidden without it, and the page itself re-checks on direct URL entry. - Same Records-tab shape as
/team/finances(summary strip, journal, period control, department/ currency/type filters minus Project, which is implicit here) — writes still go through the owner ledger endpoints (:manage), just pre-scoped to this project.
Task payouts (task detail page → "Task payout" card)
In plain terms: a per-assignee compensation offer on a specific task, that assignee must explicitly accept or decline — not an automatic consequence of being assigned.
Lifecycle: DRAFT (created, invisible to the assignee) → Send to assignee → PROPOSED → assignee Accept/Decline → ACCEPTED/DECLINED. Editing the amount/currency of an already-sent offer resets it back to DRAFT — a changed offer must be explicitly re-sent, never silently swapped under an existing answer. A DECLINED offer can be re-proposed.
- Rendered only for payout managers (owner tier or the task's department leader) and for an assignee who has at least one non-
DRAFTpayout of their own on this task. - No-account contractors (an HR Pool entry not linked to a
User): can't respond in-app, so a manager records the outcome out-of-band instead — "Record as accepted/declined (agreed outside the platform)". Rejected outright for any assignee who does have an account — a manager can never put words in a real account's mouth (taskPayout.assigneeCanRespond).assigneeHasAccount/isMineon the response DTO are computed server-side specifically so an HR-Pool-linked account (whose linkeduserIdisn't otherwise exposed) still sees its own Accept/Decline buttons (2026-07-30 fix — previously only the dashboard banner surfaced this case). - Deleting a payout is blocked once it has any recorded real payment against it, or a pending payment report — both would silently orphan a real transaction or an open question with the recipient.
Payment confirmation (finance stage 5)
A manager saying "I sent the money" and it actually arriving are two different facts — the real ledger EXPENSE entry is only created once the recipient confirms receipt themselves, never on the manager's word alone (2026-07-30 fix: the old "Mark as paid" created the entry immediately, on the manager's report alone).
- Report a payment (
platform:finance:manageonly — not department leaders, see the permission table above) — amount/date/description, capped at the payout's remaining owed amount. Files a claim only; creates noFinanceEntryyet. Only one report can be in flight per payout. - The assignee sees a banner ("The owner reports sending X on Y — did it arrive?") and either Confirm receipt (creates the real
EXPENSEFinanceEntry, attributed to whoever reported the transfer, not whoever confirmed it) or I didn't receive it (disputes it, no entry created). - For a no-account contractor: Record as received/not received (agreed outside the platform) — same manager-only, accountless-assignee-only split as the offer-acceptance path.
- The reporting manager can Cancel payment report to withdraw their own claim (e.g. a typo) without waiting for the recipient to dispute it.
paidAmount/lastPaymentConfirmedAton the payout are derived from its confirmedFinanceEntryrows — a payout can be paid in several partial confirmed payments, never assumed paid-in-full just because it'sACCEPTED.
Dashboard banner (/team/dashboard)
"My payouts" — shown only when the current user has something awaiting their own answer, across every task at once: a PROPOSED offer (accept/decline the terms) or an ACCEPTED payout with a pending payment report (confirm/dispute receipt). Backs the case where someone doesn't already know which task to open (2026-07-30 fix — previously only discoverable by opening the exact task).
Testing this module
Owner ledger CRUD, department-book leader gating (including the negative "not the leader" case), Reconciliation mismatch detection, the project finance slice, and the full task-payout lifecycle (draft → send → accept → report a payment → confirm receipt) are covered by the permanent suite scripts/e2e/menu/finance.mjs (14/14, rerunnable anytime — see scripts/e2e/README.md). The Reports/Analytics tab, multi-currency summary rendering, deletion, the decline/dispute paths, and the no-account-contractor manual-confirm paths are visual or branchy enough that they're tracked as a manual pass instead — see docs/MANUAL_TESTING.md.