-
Notifications
You must be signed in to change notification settings - Fork 2
ADR 029 Quotation Invoices In ActualCost
Accepted
The budget line response shape exposes two scalar aggregates over linked invoices:
-
actualCost— documented inshared/src/types/budget.tsas "sum of all linked invoices (any status)" -
actualCostPaid— sum of contributions where the invoice/deposit status ispaidorclaimed
The intent of actualCost is "committed/expected spend against this budget line" — the homeowner has either been billed (received/paid/claimed) or has a price commitment in writing (quotation). The intent of actualCostPaid is "money actually out the door", which powers the subsidy-payback invariant ("only paid invoices count toward payback") and the project-level cash-flow perspective.
In practice, the server aggregate (server/src/services/shared/depositAggregateUtils.ts:179) explicitly excludes quotation-status invoices from actualCost:
// Skip quotations from actualCost (matches existing behavior)
if (ibl.invoiceStatus === 'quotation') continue;This silently makes actualCost === 0 for any budget line whose only linked invoice is a quotation, even though the contract comment says "any status". The frontend was written against the contract, not the implementation, and assumes quotations are present:
-
client/src/components/budget/BudgetLineCard.tsx:50rendersactualCost × 0.95 – actualCost × 1.05when the link is a quotation — producing€0.00 – €0.00(bug #1440). -
client/src/lib/budgetConstants.ts:42-58computeBudgetTotalsrolls upactualCost × 0.95/1.05for quotation lines andactualCostfor non-quotation invoiced lines — quotations contribute zero (bug #1441 rollup). -
client/src/components/CostBreakdownTable/CostBreakdownTable.tsx:80-83does the same.
The server-side overview/breakdown services (budgetOverviewService.ts, budgetBreakdownService.ts) carry their own per-line invoice maps that do include quotation amounts and route them into projectedMin/projectedMax via the ±5% margin — so the overview rollup is internally consistent but does not match the line-level scalar exposed via GET /api/work-items/:id.
We need a single, well-defined semantic for actualCost that:
- Matches the documented contract and how every existing consumer already reads it.
- Preserves the subsidy-payback invariant (only paid invoices count).
- Minimises new API surface area.
- Keeps "money out the door" easily expressible.
actualCost includes quotation-status invoices. No new scalar is added to the budget line response shape.
Specifically:
-
Line-level
actualCost(BaseBudgetLine.actualCost, returned byGET /api/work-items/:id,GET /api/household-items/:id, list endpoints):SUM(invoice_budget_lines.itemized_amount)across all linked invoices regardless of status, includingquotation. For quotation-linked lines this is a point estimate; consumers express the ±5% range by multiplying byCONFIDENCE_MARGINS.quote(0.05). -
Line-level
actualCostPaid: unchanged — sum of contributions where invoice/deposit status ispaidorclaimed. Quotations are never counted (a quotation can never be inpaid/claimedstatus), so the subsidy-payback invariant is preserved automatically. -
Project-level
actualCost(BudgetOverview.actualCost): same semantic — sum across all linked invoice itemized amounts including quotations. The wiki has always documented this as "all invoices linked to budget lines"; this aligns the implementation with the contract. -
Project-level
actualCostPaid/actualCostClaimed: unchanged. -
BudgetLineInvoiceLinkgains denormalizedvendorId: string | nullandvendorName: string | nullso the work-item view can render the vendor for a linked quotation without a separateGET /api/invoices/:id(bug #1441 vendor display).
The implementation change is confined to server/src/services/shared/depositAggregateUtils.ts (computeDepositAwareAggregates) and server/src/services/budgetOverviewService.ts / budgetBreakdownService.ts (the WHERE i.status != 'quotation' clause in the project-level aggregate query). The frontend already expects this semantic and requires no changes for the rollup; only the line-level display fix in BudgetLineCard (it already multiplies by 0.95/1.05) becomes correct automatically once actualCost carries the quoted amount.
-
Option B: introduce a separate
quotedAmountscalar. KeepactualCostas "non-quotation only" (matching the current backend) and addquotedAmountfor quotations only. Frontend rollups becomeactualCost + quotedAmount. Rejected because:- Six existing frontend call sites already encode the assumption that quotations are in
actualCost(multiplying by 0.95/1.05 conditionally). Option B requires editing all of them. - The published contract (wiki and code comments) already says "any status". Option B would require a contract amendment everywhere
actualCostis documented; Option A reconciles code with the published contract. - One more field on every budget line response without a clear use case the existing fields cannot serve.
- Six existing frontend call sites already encode the assumption that quotations are in
- The documented contract (
actualCost= "any status") now matches the implementation. - Bug #1440 (quoted line shows €0.00) is fixed by data, not by code change in the frontend.
- Bug #1441 rollup (quotations omitted from total cost) is fixed at the source: every consumer of
actualCost— includingBudgetSummaryCard's "Actual Spend" display — now reflects committed/expected spend including quotations. - Vendor name renders on the work-item view's invoice link row without a follow-up fetch.
- "Actual Spend" on the dashboard now includes quoted amounts, not just billed amounts. This is the intended UX for a homeowner tracking commitments, but reports/exports built on
actualCostneed a doc note clarifying the inclusion. The semantic shift is local to UI labels —actualCostPaid(which powers subsidy-payback and the "money out the door" perspective) is unchanged. - Consumers that genuinely want "billed only, no quotations" must use
actualCostPaid(paid + claimed) or filter viainvoiceLink.invoiceStatus. We do not currently surface a separate "billed but unpaid" aggregate; if a future epic needs it, it can be added without amending this decision. - A quotation appearing in
actualCostat face value (no ±5% margin applied) means scalar consumers under-report the upper bound of expected spend. The frontend handles this by multiplying by 0.95/1.05 at the point of display wheneverinvoiceLink.invoiceStatus === 'quotation'. The sharedCONFIDENCE_MARGINS.quote = 0.05constant is the canonical source for that factor.
- No database schema change.
invoice_budget_lines.itemized_amountandinvoices.statusare already first-class. The change is in the aggregation layer only.