Skip to content

ADR 029 Quotation Invoices In ActualCost

Frank Steiler edited this page May 17, 2026 · 1 revision

ADR-029: Quotation Invoices Are Included in actualCost

Status

Accepted

Context

The budget line response shape exposes two scalar aggregates over linked invoices:

  • actualCost — documented in shared/src/types/budget.ts as "sum of all linked invoices (any status)"
  • actualCostPaid — sum of contributions where the invoice/deposit status is paid or claimed

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:50 renders actualCost × 0.95 – actualCost × 1.05 when the link is a quotation — producing €0.00 – €0.00 (bug #1440).
  • client/src/lib/budgetConstants.ts:42-58 computeBudgetTotals rolls up actualCost × 0.95/1.05 for quotation lines and actualCost for non-quotation invoiced lines — quotations contribute zero (bug #1441 rollup).
  • client/src/components/CostBreakdownTable/CostBreakdownTable.tsx:80-83 does 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:

  1. Matches the documented contract and how every existing consumer already reads it.
  2. Preserves the subsidy-payback invariant (only paid invoices count).
  3. Minimises new API surface area.
  4. Keeps "money out the door" easily expressible.

Decision

actualCost includes quotation-status invoices. No new scalar is added to the budget line response shape.

Specifically:

  • Line-level actualCost (BaseBudgetLine.actualCost, returned by GET /api/work-items/:id, GET /api/household-items/:id, list endpoints): SUM(invoice_budget_lines.itemized_amount) across all linked invoices regardless of status, including quotation. For quotation-linked lines this is a point estimate; consumers express the ±5% range by multiplying by CONFIDENCE_MARGINS.quote (0.05).
  • Line-level actualCostPaid: unchanged — sum of contributions where invoice/deposit status is paid or claimed. Quotations are never counted (a quotation can never be in paid/claimed status), 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.
  • BudgetLineInvoiceLink gains denormalized vendorId: string | null and vendorName: string | null so the work-item view can render the vendor for a linked quotation without a separate GET /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.

Alternatives considered

  • Option B: introduce a separate quotedAmount scalar. Keep actualCost as "non-quotation only" (matching the current backend) and add quotedAmount for quotations only. Frontend rollups become actualCost + 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 actualCost is 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.

Consequences

Easier

  • 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 — including BudgetSummaryCard'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.

Harder

  • "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 actualCost need 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 via invoiceLink.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 actualCost at 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 whenever invoiceLink.invoiceStatus === 'quotation'. The shared CONFIDENCE_MARGINS.quote = 0.05 constant is the canonical source for that factor.

Neutral

  • No database schema change. invoice_budget_lines.itemized_amount and invoices.status are already first-class. The change is in the aggregation layer only.

Clone this wiki locally