Skip to content

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 workspace: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 workspace: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.

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 or reserved, not yet spent), or Expense (money actually spent). These three are never merged into one running total.
  • Amount — required, a positive number. Whether it adds to or subtracts from a total comes entirely from Type, not from a sign you set separately.
  • Currency — required: AMD / USD / EUR / RUB. Every total is computed per currency, with no conversion between them — the same spend recorded in two currencies produces two separate totals.
  • Date — required, defaults to today. It's this date, not when you actually saved the entry, that decides which month it's grouped under and which period filters pick it up.
  • Description — required, 1–2,000 characters.
  • Project — optional (hidden on a project's own Finances tab, where it's implicit). Attributes the entry to a project for the Reports tab's breakdown; leave as No project otherwise.
  • Department — optional (hidden or pre-filled wherever the book itself is already scoped to one department). Same idea as Project, for the Reconciliation tab's 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, minus Department.

Edit or delete an entry in your department's book — the entry's row → ⋯ → Edit entry or Delete entry (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 — same Add entry / ⋯ → Edit entry / Delete entry as the owner ledger, just pre-scoped to this project. Same field guide.

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.

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, and only settable when creating. Only people already assigned to this task who don't already have a payout show up here; once everyone has one, Add payout itself disappears from the card.
  • Amount — required, a positive number.
  • Currency — required: AMD / USD / EUR / RUB, same list as the ledger.
  • No Description or Date field — a payout is just an amount owed to someone, not a ledger line yet; those appear later, on the payment report (below).
  • A new payout always starts as Draft, invisible to the assignee until you explicitly Send it. Editing the Amount/Currency of an already-sent offer resets it back to Draft — it has to be sent again before the assignee sees the change.
  • Delete is blocked once any real payment has been recorded against it, or while a payment report is still pending — cancel the report first (see Payment confirmation, below).
  • A Task estimate mismatch warning appears above this list if the sum of every accepted payout exceeds the task's own Cost field — a soft warning only, nothing is actually blocked by it. Payouts in a different currency than the task's own get a separate warning instead, since the two can't be compared directly.

Payment confirmation ​

Record that you sent someone their payout — on an accepted payout, ⋯ → Report a payment → amount/date/description are pre-filled → submit. This doesn't touch the ledger yet — the recipient still has to confirm it arrived. See field guide.

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 (not the full amount) — a partial payment doesn't need hand-editing down from the total. Entering more than what's still owed is rejected.
  • Date — required, pre-filled to today.
  • Description — required, pre-filled with a generated line, fully editable, up to 2,000 characters.
  • Only one report can be pending at a time per payout — Report a payment disappears from the ⋯ menu until the current one is confirmed, disputed, or cancelled.
  • Nothing here touches the ledger until the report is confirmed — that's the step that actually creates the real expense entry.

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.

Notifications ​

Money is the one area where every event needs an answer from someone, so all of it is Important: in the bell and by email. Full explanation in Notifications.

Payouts ​

An offer is made — the assignee is told, with the amount. Until they answer, nothing else in the flow can move.

The offer is accepted or declined — whoever made it is told, along with the note if one was left. They now hold the next step.

A payment is reported — the recipient is told and asked to confirm they received it. No ledger entry exists until they do, so this notification is what starts that step.

A payment is confirmed or disputed — whoever reported it, and the offer's author. A dispute is their problem to resolve either way.

The ledger itself — recording income, budget or expenses — notifies nobody. Those are your own records, and nobody else is waiting on them.

The permission model ​

Two catalog permissions plus one data-driven rule, layered:

WhoWhat they can do
workspace:finance:readRead the owner ledger and every department's book (read-only for the latter)
workspace:finance:manageWrite the owner ledger, and report/confirm a real payment — the only permission that can move ledger money
workspace:finance:payouts:readSee every payout on the team's tasks, drafts included — and offer nothing
workspace:finance:payouts:manageOffer, edit, propose and cancel payouts on the team's tasks
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 two payout permissions were split out of workspace:finance:manage on 2026-09-09, because until then seeing what the team is being paid and being able to change it were the same thing — so oversight could only be granted by handing over control of everyone's pay. Now they are separate switches on the Team roles, and payouts:manage implies payouts:read (a permission to offer a payout you may not look at would be one nobody could use). Reporting and confirming an actual payment stayed on workspace:finance:manage: that writes a real ledger entry.

Nothing changed for anyone at the moment of the split — Team Manager was granted both keys, and every custom role that already had workspace:finance:manage was backfilled with them.

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." It is also the one route into payouts that no permission can close: a department leader sees and manages every payout on tasks in their department, and the only way to stop that is to stop them leading the department. workspace:finance:manage is deliberately excluded from P4P Internal's Team Admin tier's grant — finances are for the Team Manager tier only (docs/staffing/ADR-002-org-rbac-migration.md; SUPER_ADMIN/P4P Owner/P4P Admin platform roles are unrelated to this gate since 2026-08-18 — see Staffing for how a platform role can optionally still reach Team Manager via linkedOrgRoleId, but that's a convenience on top, not the underlying check).

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 workspace: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 from type), 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, comparison internally) — 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 workspace:finance:read holders (read-only, via the owner-scoped listing — the leader-only route 403s them). workspace:staffing:read alone (no finance access at all) never sees the Finances tab in the tab bar at all; navigating to its URL directly still shows a "not available" message rather than the book (the page's own defense-in-depth check, same idea as the project Finance tab below).
  • 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 workspace:finance:read holders — 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.

Rates and invoices (/team/invoices) ​

In plain terms: set a project's client and hourly rate, turn its logged hours into an invoice, issue it, mark it paid — and see whether the project pays off.

  • Client and rate — on the project's Finances tab (workspace:finance:manage to change). One hourly rate per project; a new rate only affects new invoices.
  • Invoice from hours — same tab: pick a period and one line per person or per task. It takes the hours logged on the project in that period that are on no invoice yet, × the rate, and makes a draft. Those hours are then taken: they cannot be billed again, and their hours and day cannot be edited, until the draft is deleted or the invoice voided.
  • Draft → issued → paid, or void — a draft can be edited or deleted. Issuing gives the next number (2026-0001) and freezes the invoice; it needs your company in Invoice settings (menu on /team/invoices). An issued invoice is marked paid (the project gets an income entry in the ledger) or voided with a reason — it stays in the list, and its number is never reused. "Overdue" means issued, unpaid and past its due date.
  • Print / PDF — the invoice's "Print / PDF" opens a clean page; choose "Save as PDF" in the print window.
  • Profitability — on the project's Finances tab: invoiced, paid, awaiting payment, the cost (logged hours × each person's cost rate, plus agreed task payouts) and the margin, per currency.
  • Cost rates — what an hour of each person costs the company: "…" menu on /team/finances → Cost rates. Finance only; people without one are named on the project, since their hours count at zero cost.

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-DRAFT payout 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/isMine on the response DTO are computed server-side specifically so an HR-Pool-linked account (whose linked userId isn'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).

  1. Report a payment (workspace:finance:manage only — not department leaders, see the permission table above) — amount/date/description, capped at the payout's remaining owed amount. Files a claim only; creates no FinanceEntry yet. Only one report can be in flight per payout.
  2. The assignee sees a banner ("The owner reports sending X on Y — did it arrive?") and either Confirm receipt (creates the real EXPENSE FinanceEntry, attributed to whoever reported the transfer, not whoever confirmed it) or I didn't receive it (disputes it, no entry created).
  3. 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.
  4. The reporting manager can Cancel payment report to withdraw their own claim (e.g. a typo) without waiting for the recipient to dispute it.
  5. paidAmount/lastPaymentConfirmedAt on the payout are derived from its confirmed FinanceEntry rows — a payout can be paid in several partial confirmed payments, never assumed paid-in-full just because it's ACCEPTED.

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).

Owner ledger, department books, project finances ​

The ledger notifies nobody. Recording income, a budget or an expense is your own bookkeeping — nobody is waiting on it, and money is deliberately kept out of the shared activity feed.

Payouts are the exception, and they are not on this page: they live on a task's own detail page, where each offer and answer tells the other side. See Payouts.

Testing this module ​

Department-book leader gating (including the negative "not the leader" case) is covered live by the permanent suite scripts/e2e/menu/finance.mjs (4/4, rerunnable anytime — see scripts/e2e/README.md).

Owner ledger CRUD, Reconciliation, the project finance slice, and the full task-payout lifecycle (draft → send → accept → report a payment → confirm receipt) are a documented live-coverage gap as of 2026-08-21, not actually exercised by that suite run despite being written — the 2026-08-18/19 RBAC migration made workspace:finance:read/:manage Owner-tier-only in P4P Internal with no SUPER_ADMIN bypass (deliberate — see docs/staffing/IMPLEMENTATION_PLAN.md), and e2e-test-admin (the dedicated account this whole suite runs as) only ever holds "Team Admin" tier, never "Team Manager"/Owner. finance.mjs probes this itself at the top of its run (hasFinanceAccess()) and skips the gated checks cleanly with one explicit record instead of cascading into confusing timeouts, rather than silently pretending to cover them. Giving the test account Owner tier was considered and rejected — see the probe's own comment for why (a second "P4P Owner" platform-role holder risks breaking PlatformRolesService.transferOwnership()'s unordered lookup of "the current Owner" for whoever really holds it, on a real local dev environment). The component-level read-route branching these flows exercise (leader vs workspace:finance:read) is still covered by the unit tests below, independent of this live-coverage gap. The owner ledger's own Records, Reports/Analytics AND Comparison tabs (/team/finances, all three blocks of a 3-block round split for that 991-line page now done), the department's own book, and the project-scoped slice all have their own unit test files (apps/portal/pages/team/finances/index.test.ts, apps/portal/pages/team/departments/[id]/finances.test.ts, apps/portal/pages/team/projects/[id]/finances.test.ts) covering the read-route branching by role (leader vs workspace:finance:read), department budget aggregation (BUDGET/EXPENSE summed per currency, INCOME excluded), the currency summary's vs-previous-period delta, the tab+period URL sync, create/edit/delete wiring, the trend chart's own data (which currency it defaults to, the zoom bounds, INCOME excluded from it too), the project/department breakdown tables (INCOME excluded, an unattributed entry lands in a residual bucket rather than silently vanishing from the total), the drill-into-Records handoff (clicking a breakdown row switches tabs and pre-filters, clearing the other dimension's filter rather than leaving it stale), and Comparison's own data logic (comparisonRows grouping owner + department entries by department × currency with INCOME excluded from both sides, the departmentCount/currencyCount/matchCount/diffCount summary, the show/hide-matches toggle defaulting matches to hidden, the tab badge tracking the diff count and disappearing once everything matches, and Comparison staying scoped to the shared period while ignoring the journal-only search/currency/type/project/department filters) in detail. The chart's own visual rendering (bar geometry, hover tooltips, zoom as actually dragged/clicked in a browser), multi-currency summary rendering, 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.