Skip to content

fix(budget): use invoice-based cost basis for subsidy reductions#614

Merged
steilerDev merged 3 commits into
betafrom
feat/605-subsidy-recalculation
Mar 8, 2026
Merged

fix(budget): use invoice-based cost basis for subsidy reductions#614
steilerDev merged 3 commits into
betafrom
feat/605-subsidy-recalculation

Conversation

@steilerDev
Copy link
Copy Markdown
Owner

Summary

Fixed subsidy reduction cost basis in budgetOverviewService.ts. When computing subsidy reductions (both percentage and fixed), the service now uses:

  • Invoice amount if the budget line has linked invoices (itemized_amount sum)
  • Planned amount if no invoices are linked

This ensures subsidies reduce the actual cost rather than the planned estimate when invoices are present.

Changes

Step 7 (per-line subsidyReduction)

  • Resolved cost basis from lineInvoiceMap or falls back to plannedAmount
  • Percentage subsidy: reduction = costBasis * (reductionValue / 100)
  • Fixed subsidy: reduction = min(perLineAmount, costBasis) where perLineAmount = reductionValue / matchingLineCount

Step 11a (totalReductions)

  • Applied identical cost basis resolution and formulas
  • Ensures sum of all reductions matches individual line calculations

Files Modified

  • server/src/services/budgetOverviewService.ts — Lines 199-248 (step 7) and 419-454 (step 11a)

Validation

  • TypeScript typecheck: PASS
  • No new imports or type changes required
  • lineInvoiceMap already in scope for both loops

🤖 Generated with Claude Code

claude added 3 commits March 8, 2026 21:24
…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>
Copy link
Copy Markdown
Owner Author

@steilerDev steilerDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

@steilerDev steilerDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

@steilerDev steilerDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@steilerDev steilerDev merged commit 99f0ca3 into beta Mar 8, 2026
13 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 8, 2026

🎉 This PR is included in version 1.14.0-beta.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 9, 2026

🎉 This PR is included in version 1.14.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@steilerDev steilerDev deleted the feat/605-subsidy-recalculation branch March 22, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants