Skip to content

ADR 039 One Money Service

Frank Steiler edited this page Oct 8, 2026 · 2 revisions

ADR-039: One Money Service ("Left to Spend")

Status

Accepted (2026-10-08), planned — EPIC-21 (UX restructure, #2190). Not implemented yet; plan story 3.1 implements it, contract 2 of API Contract › EPIC-21 is its HTTP surface.

Owner decisions: Q4 with the strict verdict (§2.0 #23), grants as a percentage of explicitly eligible items (§2.0 #15, #22), Q3 (no schema change). Builds on ADR-029 (offers count as cost), ADR-030 (orphan lines) and ADR-036 (per-source scoping).

Context

The same question, "how much money is left?", is answered differently on different screens because the figures are computed in several places:

  • budgetOverviewService.ts computes grant payback twice in one request: perSubsidyReductionTotals → totalReductions (capped by a second applySubsidyCaps call, #1808) and computeSubsidyEffects + applySubsidyCaps → minTotalPayback / maxTotalPayback.
  • budgetBreakdownService.ts has a third grant computation whose total differs from both of the others (on the owner's data it lands above the capped figure).
  • GET /api/budget/overview exposes five remainingVs* perspectives plus two payback-adjusted ones; the client picks different ones on Home and on the Budget page, and Home averages two scenarios and leaves grants out.
  • An offer or invoice amount that is not yet split onto a task or purchase (invoices.amount minus the itemized invoice_budget_lines) counts nowhere, so the budget looks better than the paperwork.
  • Confidence margins apply to offer-linked lines (±5%) although an offer's amount is a signed figure.
  • The verdict ("At Risk", "Over Budget") can contradict its own figure and appears with no funding or no cost at all.

Decision

1. One service, one computation

A single server module, server/src/services/moneyService.ts (new), computes every money figure the UI shows. Every endpoint that returns a money figure — budget overview, breakdown, Home attention rows, the companies list, area summaries, the work-items list's expected cost, funding-source and grant pages — calls it instead of computing its own. The legacy grant paths in budgetOverviewService.ts and budgetBreakdownService.ts are deleted when their last consumer moves (story 3.1 keeps the old response fields, now derived from the money service, so no client breaks mid-epic).

The pure computation lives beside today's server/src/services/shared/subsidyCalculationEngine.ts (it reuses applySubsidyCaps); the database reads are one set of queries per request, not per item.

2. Definitions

Left to spend = Funding total + Expected grants − Expected cost

Term Definition
Funding total SUM(budget_sources.total_amount) over sources with status = 'active', own funds included (today's availableFunds).
Expected cost For every budget line (work-item and household-item lines, orphans included): if it is linked to an offer or invoice through invoice_budget_lines, its itemized amount at face value (gross); otherwise its planned amount, gross (grossed up by VAT_RATE when entered net). Plus the not-yet-split remainder of every offer and invoice: amount − SUM(itemized_amount), counted when ≥ 1.00 in the configured currency. Refund entries never add cost.
Expected grants For grants with application_status IN ('approved', 'received') only: rate × eligible cost, capped at maximum_amount × rate when maximum_amount is set (§3). Grants that are eligible or applied are returned as possible, not counted. rejected grants are ignored.
Range The same formula evaluated twice more, with every unlinked line at the low and the high end of its basis margin: own estimate ±20%, professional estimate ±10%, offer ±5%, invoice 0% (CONFIDENCE_MARGINS). Linked lines stay at face value in all three evaluations. Grants are recomputed per evaluation (a lower cost yields a lower percentage grant).
best / worst best = all unlinked lines at their low end; worst = all at their high end. expected = the planned amounts as entered.

All amounts are rounded to cents per line before summing, as today (ADR-018 rounding).

3. Grants (§2.0 #15, #22)

  • Percentage only. A grant states its rate (reduction_value, percent) and an optional eligible-costs cap (maximum_amount, the cap on eligible costs as in the grant letter). The expected grant is min(rate × eligible cost, rate × cap). This matches the existing applySubsidyCaps semantics, where maximum_amount of a percentage grant is the eligible-cost cap.
  • Only explicitly listed items are eligible. Eligible cost = the expected cost of the lines of the work items and household items linked to the grant (work_item_subsidies, household_item_subsidies), optionally narrowed by its cost categories (subsidy_program_categories, with includes_no_category_items for uncategorised lines). A grant with no linked items contributes 0 — there is no project-wide grant. The UI requires at least one item when a grant is added; the API keeps accepting a grant without items so existing data stays valid.
  • The payout is not tracked. received is a status (the end marker), not an amount; no received amount is stored or asked for.
  • Fixed amounts are not offered. reduction_type stays in the schema; the UI never offers fixed. Existing fixed rows, if any, are still computed as today (amount per linked item, capped) and shown read-only, so no stored data is silently ignored.
  • Pro rata per item and area. A grant's expected payout is spread over its eligible lines in proportion to their eligible cost (after the cap scales it down). Per-item and per-area "Expected grants" and "After grants" are sums of these shares, so they always add up to the project total.

4. The verdict (strict, §2.0 #23)

Condition Verdict
no active funding source, or expected cost = 0 null (no verdict is shown)
leftToSpend.expected < 0 over_budget
otherwise on_budget
  • There is no "Tight" verdict. The range (best / worst) is shown inside the figure ("−€X · Over budget · range −€Y to +€Z"), so uncertainty is visible without a third verdict.
  • A verdict never contradicts its figure: over_budget only with a negative figure, on_budget only with zero or a positive one. This is a unit-tested invariant.

5. The label registry (closed list)

Every money figure in the UI renders through one component (MoneyFigure) with a label from this closed registry and the definition as its tooltip. A figure without a registry label is a review finding.

Label Definition
Left to spend §2 (with range and verdict)
Funding total §2
Expected cost §2
Expected grants §2, §3
After grants Expected cost − expected grants (pro rata)
Paid Invoices paid or claimed, plus progress payments (deposits) paid or claimed on records not fully paid
Still to pay Invoices pending minus their paid progress payments, plus progress payments pending (also on offers). claimed never counts
Not yet paid Expected cost − Paid, per line at the chosen estimate level
Ready to submit Items pending or paid (not claimed) funded by a source of type bank_loan or credit_line: exactly the set the bank report's draw-down request lists. Own funds never count
Remaining on offer Offer amount − its progress payments
Not yet split Offer or invoice amount − itemized amount (≥ 1.00)
Allocated Expected cost of the lines funded by a source
Unallocated Source amount − allocated, when ≥ 0
Over-allocated Allocated − source amount, when > 0 (shown as is, §2.0 #18)
Submitted Paid amounts included in a draw-down request (claimed), per source
Amount A record's own amount
Eligible costs up to A grant's maximum_amount
Estimate The planned amount of an unlinked line, with its basis margin
Received A grant status only; no amount

Per-source figures (Allocated, Paid, Submitted, Ready to submit) keep today's tagged-deposit rules (Rail A / Rail B in budgetSourceService.ts) and the scope rules of ADR-036; the money service reuses that aggregation rather than re-deriving it.

6. No schema change (Q3)

Everything above is computed from existing columns. Explicitly out (owner decision Q3, closed, not parked): an offer entity (O1), a paid date on invoices (O5), several trades per company (O4), a task in several areas (O6). Their UI answers are in the plan §7.6.

7. Verification

  • A synthetic regression fixture (same shape and arithmetic as a real project, renamed entities, scaled amounts — never real data, Q18) pins every registry figure.
  • A consistency test (plan story 3.5) renders Home, Money, Financing, a company page and the Invoices footer and asserts equal figures for equal labels.

Consequences

What becomes easier

  • One number per label on every screen; Home and Money can no longer disagree.
  • The figure counts everything signed or billed, so it can only get worse by new decisions, not by bookkeeping (splitting an offer onto tasks no longer changes Left to spend).
  • Grant figures are explainable from the grant letter (rate, cap, listed items).

What becomes harder

  • The figure is stricter than today's: a project whose open estimates make the range cross zero now reads "Over budget" as soon as the expected figure dips below zero. This is the owner's explicit choice.
  • Removing the project-wide grant behaviour means a grant that relied on category matching alone now shows 0 until its items are listed; the grant page must make that visible (an empty "eligible items" section with Link items).
  • The legacy overview fields must be kept (derived) until every consumer moves, then removed in one breaking-change-free sweep (no external API consumers exist).

Alternatives considered

  • Symmetric verdict with "Tight" (plan's first recommendation): rejected by the owner (§2.0 #23).
  • Midpoint of today's min/max planned: ignores unsplit amounts and puts margins on signed offers.
  • Store a computed left_to_spend snapshot: rejected; a derived figure must not be stored (it would need invalidation on every money write, for a dataset small enough to compute per request).

Deviation Log

Date Section Deviation Resolution

Clone this wiki locally