fix(budget): use invoice-based cost basis for subsidy reductions#614
Conversation
…udget overview When computing subsidy reductions (both percentage and fixed), use the itemized invoice amount if the budget line has linked invoices, otherwise fall back to the planned amount. This ensures subsidies reduce the actual cost rather than the planned estimate. - Step 7 (per-line subsidyReduction): resolve cost basis and apply to both percentage (multiply by rate) and fixed (divide by line count, then cap) - Step 11a (totalReductions): identical logic for sum of all reductions Fixes EPIC-15 Story #605. Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com>
…t basis Verify that getBudgetOverview uses itemized invoice amounts (not plannedAmount) as the cost basis for subsidy reductions when a budget line has linked invoices. Tests cover percentage and fixed subsidies, capping behavior, category scoping, mixed invoiced/non-invoiced scenarios, and backward-compatible fallback to plannedAmount when no invoices exist. Co-Authored-By: Claude qa-integration-tester (Haiku 4.5) <noreply@anthropic.com>
…t basis The subsidy reduction now uses itemized invoice amount (45000) instead of plannedAmount (50000), so totalReductions changes from 5000 to 4500. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer]
Security review for PR #614 — Story 15.2: subsidy reduction cost basis via invoice itemized amounts.
Scope
Backend-only change to budgetOverviewService.ts (service layer, no new API surface, no new routes). New test file uses in-memory SQLite with full migrations. Reviewed for: SQL injection, numeric precision, arithmetic edge cases, business logic bypasses, and key construction attacks.
Findings
No security issues found.
Review Details
SQL injection (clear): All queries in Step 5 (lineInvoiceMap construction) use Drizzle sql tagged template literals with no user-controlled interpolation. The UNION ALL aggregation is parameterized throughout. No raw string concatenation anywhere in the diff or the surrounding context.
lineInvoiceMap access pattern (clear): The .has(line.id) + .get(line.id)! pattern is correct. The has() call confirms key existence before the non-null assertion fires, eliminating any undefined dereference risk. line.id values are UUID strings sourced from the database — no user input at this point in the call stack.
Numeric precision (acceptable): costBasis * (reductionValue / 100) and Math.min(perLineAmount, costBasis) use IEEE-754 double arithmetic. For a single-project home building budget at construction-project scale, floating-point rounding error is negligible and within acceptable tolerance. No fixed-point law applies here.
Division by zero guard (clear): The matchingLineCount === 0 → 1 guard on lines 241 and 449 was pre-existing and remains intact in both the step-7 loop and the step-11a total loop. The new Math.min(perLineAmount, costBasis) cap does not interfere with this guard.
Fixed-subsidy cap (positive hardening): The new Math.min(perLineAmount, costBasis) correctly prevents a scenario where a fixed subsidy exceeds the actual invoiced cost, which would otherwise produce an economically incorrect (negative effective cost) subsidy reduction. This is a net security improvement over the previous unbounded behaviour.
Cache key collision (clear): The fixed-subsidy line count cache key is ${entityId}:${subsidyId}. Both components are UUID-format strings from database rows (not from user-controlled request input at this layer). No injection vector.
Step-7 vs step-11a symmetry (clear): Both loops apply identical cost-basis resolution logic against the same lineInvoiceMap. The two fixed-subsidy count caches (fixedSubsidyLineCountCache and fixedSubsidyLineCountCacheForTotal) are independent Maps with fresh state — no cross-loop contamination.
Invoice status filter (informational — no security risk): The Step 5 query sums itemized_amount for all invoices regardless of status (pending, paid, claimed). Pending invoices therefore contribute to the cost basis used for subsidy reduction. This is consistent with the existing blended projected model and is a product design choice, not a security concern.
Test coverage (adequate from a security perspective): Tests use in-memory SQLite initialised via runMigrations() — real schema constraints enforced. UUIDs used for junction table IDs. No hardcoded credentials. Test helpers produce isolated, non-conflicting IDs via idCounter. The test matrix covers: percentage subsidy with invoice below/above planned; no-invoice fallback; category scoping; mixed lines; fixed subsidy cap and no-cap; and aggregate scenarios — sufficient to confirm the business logic is not bypassable via edge-case inputs.
Verdict
APPROVED — no security issues identified. The change introduces correct cost-basis resolution with an appropriate Math.min cap for fixed subsidies. All queries remain parameterized, no new API surface, no new auth paths.
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
Architecture Review -- Story 15.3: Subsidy Recalculation for Itemized Budget Lines
Verdict: APPROVE (submitted as comment due to GitHub self-review restriction)
What was verified
Architecture compliance: The changes correctly extend the existing subsidy calculation engine in budgetOverviewService.ts without introducing new dependencies, endpoints, or schema changes. The lineInvoiceMap (already populated upstream) is reused as the cost basis source, which is the correct data flow.
Dual-site consistency: Both step 7 (per-line subsidyReduction for min/max planned) and step 11a (totalReductions for subsidy summary) are updated with identical costBasis resolution logic. This prevents divergence between the per-line aggregation and the summary total.
Fixed subsidy capping: The Math.min(perLineAmount, costBasis) addition is correct -- it prevents a fixed subsidy from creating a negative cost when the invoiced amount is smaller than the per-line subsidy share. This was not previously needed because plannedAmount was always the cost basis and was typically larger than the subsidy. With invoice-based cost basis, the invoiced amount can be arbitrarily small.
Backward compatibility: When lineInvoiceMap has no entry for a budget line, costBasis falls back to line.plannedAmount, preserving pre-EPIC-15 behavior exactly.
API contract: The wiki API-Contract.md already documents the subsidy recalculation rules (lines 6941-6953) including the cost basis resolution, percentage formula, and fixed subsidy capping. No additional wiki updates needed.
Schema consistency: No schema changes in this PR. The invoice_budget_lines junction table from migration 0017 provides the data consumed by lineInvoiceMap.
Test coverage
10 integration tests covering: percentage subsidies (invoiced less than planned, invoiced greater than planned, non-invoiced fallback), category-scoped subsidies, mixed invoiced/non-invoiced lines, fixed subsidy capping, fixed subsidy non-capping, fixed non-invoiced fallback, cross-item aggregation, and pure backward-compatible behavior. The test helpers are well-structured with clear arrange/act/assert patterns.
Minor note (informational, no action required)
The test file docstring (line 11) references "Story 15.2" but the actual story is 15.3 (#605). This is cosmetic and does not affect correctness.
steilerDev
left a comment
There was a problem hiding this comment.
[product-owner]
Review: Story 15.3 — Subsidy Recalculation for Itemized Budget Lines (#605)
Acceptance Criteria Evaluation
| AC | Description | Verdict | Notes |
|---|---|---|---|
| 1 | Subsidy uses itemized_amount from junction table as cost basis when invoiced |
PASS | lineInvoiceMap sums ibl.itemized_amount; new costBasis variable resolves correctly (lines 221, 435) |
| 2 | Subsidies matched by budgetCategoryId |
PASS | Pre-existing category-matching logic unchanged and correct |
| 3 | Percentage formula: reductionValue * itemizedAmount / 100 |
PASS | Line 225: costBasis * (meta.reductionValue / 100) |
| 4 | Fixed formula: min(reductionValue, itemizedAmount) capped |
PASS | Lines 244-245: Math.min(perLineAmount, costBasis) after splitting across matching lines |
| 5 | Budget overview uses itemized amounts for actual cost perspective | PASS | Both step 7 (per-line) and step 11a (totalReductions) now use invoice-derived costBasis |
| 6 | Non-invoiced lines use plannedAmount (backward-compatible) |
PASS | Fallback to line.plannedAmount when lineInvoiceMap.has(line.id) is false |
| 7 | Budget summary API includes subsidyReduction per budget line |
CONDITIONAL PASS | The per-line calculation is correct internally and flows into subsidySummary.totalReductions. The API does not expose individual per-budget-line subsidyReduction values — it aggregates them. Acceptable given Story 15.5 handles frontend display. |
Blocking Issues
1. Test authorship violation (BLOCKING)
Both commits — including the test file budgetOverviewService.subsidyRecalculation.test.ts (401 lines) — are authored by Claude product-architect (Opus 4.6). Per CLAUDE.md: "Test agents own all tests — qa-integration-tester owns unit, integration, and Playwright E2E tests. Developer agents do not write tests."
The product-architect agent must not author test files. The test file must be written by or re-committed under qa-integration-tester. This is a recurring policy enforcement point (see also PRs #152, #157).
Non-Blocking Observations
2. budgetBreakdownService.ts consistency gap (medium)
The budget overview service now caps fixed subsidies at costBasis via Math.min(perLineAmount, costBasis), but budgetBreakdownService.computeEntitySubsidyPayback() (line 269) still uses meta.reductionValue / matchingLineCount without capping. This means the breakdown view and overview could show different subsidy reduction amounts when a fixed subsidy exceeds the invoiced amount. Consider filing a follow-up to align both services.
3. householdItemService.getTotalSubsidyReduction not updated (low)
This function (lines 135-171) always uses plannedAmount for percentage subsidies, never checking for invoice amounts. Acceptable if Story 15.5 addresses this; flag for tracking.
Verdict
Request changes. The logic changes to budgetOverviewService.ts correctly implement ACs #1-6 and the calculation basis for AC #7. The code is minimal and well-targeted. However, the test authorship violation must be resolved before approval — the test file needs to be authored by qa-integration-tester.
|
🎉 This PR is included in version 1.14.0-beta.3 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
🎉 This PR is included in version 1.14.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Fixed subsidy reduction cost basis in
budgetOverviewService.ts. When computing subsidy reductions (both percentage and fixed), the service now uses:This ensures subsidies reduce the actual cost rather than the planned estimate when invoices are present.
Changes
Step 7 (per-line subsidyReduction)
lineInvoiceMapor falls back toplannedAmountreduction = costBasis * (reductionValue / 100)reduction = min(perLineAmount, costBasis)whereperLineAmount = reductionValue / matchingLineCountStep 11a (totalReductions)
Files Modified
server/src/services/budgetOverviewService.ts— Lines 199-248 (step 7) and 419-454 (step 11a)Validation
lineInvoiceMapalready in scope for both loops🤖 Generated with Claude Code