Skip to content

fix(invoice): make status breakdown summary deposit-aware (#1411)#1412

Merged
steilerDev merged 1 commit into
betafrom
fix/1411-invoice-summary-deposit-aware
May 12, 2026
Merged

fix(invoice): make status breakdown summary deposit-aware (#1411)#1412
steilerDev merged 1 commit into
betafrom
fix/1411-invoice-summary-deposit-aware

Conversation

@steilerDev
Copy link
Copy Markdown
Owner

Summary

  • InvoiceStatusBreakdown.summary (used by the InvoicesPage header cards and Dashboard InvoicePipelineCard) was not deposit-aware: a €1000 quotation invoice with a €200 pending deposit incorrectly showed €1000 under Quotation and €0 under Pending
  • Adds aggregateInvoiceStatusBreakdown() to depositAggregateUtils.ts and rewrites listAllInvoices() summary to split amounts across parent + deposit statuses via LEFT JOIN on invoice_deposits; counts remain per-invoice (deposit rows never increment a count)
  • Summary stays globally unfiltered (filter-independent) — consistent with existing UX; wiki API-Contract.md updated with the deposit-aware invariant and quotation example entry

Fixes #1411

Test plan

  • Unit tests for aggregateInvoiceStatusBreakdown() cover: no deposits, single deposit, multiple deposits with mixed statuses, deposit sum exceeding invoice amount (clamped to 0 residual)
  • invoiceService.test.ts verifies deposit-aware summary in listAllInvoices() for global unfiltered totals
  • Quality Gates CI passes (typecheck + unit tests + build)

InvoiceStatusBreakdown.summary (consumed by the InvoicesPage header
and the Dashboard InvoicePipelineCard) was missed when #1405 migrated
budget rollups to the deposit-aware split. A quotation invoice of
€1000 with a pending deposit of €200 showed €1000 under Quotation
and €0 under Pending — should be €800 (residual) and €200 (deposit).

Adds aggregateInvoiceStatusBreakdown() to depositAggregateUtils.ts and
rewrites listAllInvoices() summary to use it with a LEFT JOIN onto
invoice_deposits. Per-invoice split: summary[parent].totalAmount accrues
max(0, amount - Σ deposits), each summary[deposit.status].totalAmount
accrues deposit.amount; count stays per-invoice (not per row).

Summary remains GLOBAL (filter-independent) — the existing UX where
the header cards stay stable while the user filters the list is
preserved. The pre-existing "summary reflects global counts" test is
unmodified.

Wiki updated: API-Contract.md documents the deposit-aware semantic
and adds quotation to the example summary block.

Fixes #1411

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
@steilerDev
Copy link
Copy Markdown
Owner Author

[product-architect] Review: APPROVED with medium findings for refinement.

Verdict

APPROVED — the fix is correct, complete, and resolves the gap that #1405 left behind. CI is green. Wiki is updated in the same PR (deposit-aware invariant documented on summary.<status>.totalAmount, quotation example added) — Wiki Update Discipline observed.

Verified

  • Completes Invoice deposits: budget rollup integration (residual under parent status, deposits under their own state) #1405's missed surface. Invoice status summary breakdown is not deposit-aware (InvoicesPage header + Dashboard pipeline card) #1411 is precisely the rollup path that Invoice deposits: budget rollup integration (residual under parent status, deposits under their own state) #1405's spec listed but the implementation skipped (InvoiceStatusBreakdown.summary consumed by InvoicesPage header cards + Dashboard InvoicePipelineCard). No other gap remains: the budget-line/source/category/work-item/household-item rollups were already migrated in feat(invoice,budget): invoice deposits UI + deposit-aware budget rollups (#1404, #1405) #1407, and the per-invoice detail view (getInvoiceById) shows deposits[] directly rather than re-aggregating.
  • Math. Invoice-level proportional split: summary[I.status].totalAmount += max(0, I.amount − Σdeposits), summary[deposit.status].totalAmount += deposit.amount, count once per invoice. Defense-in-depth on negative residual via Math.max(0,...). Sum invariant holds: Σ summary[s].totalAmount === Σ I.amount (Scenario 4 + integration test "sum invariant" both assert this).
  • Deduplication. deposits.some(d => d.depositId === row.deposit_id) correctly handles the LEFT JOIN's row expansion (Scenario 8 explicitly tests this).
  • Global summary preserved (correct architectural call). Pre-existing test at invoiceService.test.ts:934 already pinned this: summary reflects global invoice counts and totals across ALL invoices (ignores page filter). The wiki API-Contract.md line 3820 also documents it: summary field is always the global unfiltered summary. The PR preserves both. This deviates from Invoice status summary breakdown is not deposit-aware (InvoicesPage header + Dashboard pipeline card) #1411 AC-5 ('breakdown computed for the filtered set'), but the deviation is correct — the established UX is that header cards remain stable while filtering the list ('see what the other filter values would yield'), and the deposit-aware fix is orthogonal to filter scoping. AC-5 was inconsistent with the pre-existing system; the PR correctly chose the system's source of truth (existing test + wiki) over the issue's text.
  • Performance. One LEFT JOIN over the full invoices table for the global summary. No N+1 (deduplication is in-memory by Map). At <5 users this is comfortably acceptable; even with thousands of invoices and deposits per project, this is one O(N+M) scan.
  • Test coverage. 9 unit tests on aggregateInvoiceStatusBreakdown exercise empty input, no-deposits, single deposit, sum invariant, multi-deposit residual+accrual, clamp-to-zero, multi-invoice count, dedupe, and mixed scenarios. 6 integration tests cover AC-1..AC-8 against the live SQL. Coverage is 100% on the new helper.

Medium findings (refinement-eligible, not blocking)

  1. Fourth instance of the row-grouping + deposit-dedup pattern. depositAggregateUtils.ts now has three functions (computeDepositAwareAggregates, computeStatusContribution, aggregateInvoiceStatusBreakdown) that each re-implement the same Map<invoiceId, ...> + depositsByInvoice + dedupe-by-depositId preamble (lines 51-87, 170-205, 279-297) plus a budgetSourceService.computeDiscretionaryInvoiceAmount inline implementation. My review on feat(invoice,budget): invoice deposits UI + deposit-aware budget rollups (#1404, #1405) #1407 already flagged this and recommended extracting a splitByDeposits() helper. This PR adds the fourth instance.

    Recommendation: Refactor in a follow-up — not in this PR. Reasoning: (a) the bug fix is small and focused, mixing it with a 3-call-site refactor would balloon the diff and risk breaking the working rollups; (b) the duplication is now well-isolated to one file with all four call sites visible together, which is the right shape for a clean extraction; (c) no tracking issue exists yet — please open one (refactor(invoice): extract splitByDeposits helper from depositAggregateUtils) and tag it for refinement during /epic-close or the next cleanup pass. The right abstraction is a single function that takes the raw rows + a per-invoice callback (invoiceAmount, parentStatus, deposits[]) => void, leaving the per-call-site accumulation logic to the caller.

  2. Two minor nits in the new helper (not blocking, can fold into the refactor above):

    • ensure(deposit.depositStatus) is called even for deposits whose status equals the parent invoice's status — no functional issue (idempotent), just a tiny waste.
    • The interface name InvoiceDepositRow is fine but consider InvoiceWithDepositRow to disambiguate from invoice_deposits table rows.
  3. Test comment cleanliness. invoiceService.test.ts AC-1 uses a raw SQL UPDATE to set status='quotation' because insertRawInvoice doesn't accept 'quotation' in its typed union. Consider widening that helper's status param to the full InvoiceStatus union in a follow-up — it would clean up this and any future quotation test cases. Not a blocker.

Not findings

  • The global-stays-global decision is correct and consistent with the existing system. The wiki, the pre-existing test, and this PR all align.
  • Performance concerns at scale are theoretical at this project's <5-user target; not worth optimizing.

Architecturally sound. Approving — please open the refactor tracking issue before merge so the duplication doesn't get forgotten.

@steilerDev steilerDev merged commit 7ce0ebd into beta May 12, 2026
29 of 30 checks passed
@steilerDev steilerDev deleted the fix/1411-invoice-summary-deposit-aware branch May 12, 2026 06:47
@github-actions
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.6.0-beta.4 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

steilerDev added a commit that referenced this pull request May 12, 2026
…face revert errors (#1413) (#1414)

* chore(invoice,budget): dedupe split helper, extract OverflowMenu, surface revert errors

Closes the architect's medium-severity recommendations from the #1407
and #1412 reviews. Four scopes:

1. Extract splitByDeposits() helper in depositAggregateUtils.ts and
   reuse across the 4 call sites that inlined the proportional-split +
   dedup pattern (computeDepositAwareAggregates, computeStatusContribution,
   aggregateInvoiceStatusBreakdown, and computeDiscretionaryInvoiceAmount
   in budgetSourceService.ts). Behaviour-preserving — existing tests pass
   unmodified.

2. Extract a shared OverflowMenu component (client/src/components/
   OverflowMenu/). DepositRow and DepositCard both consume it instead of
   duplicating ~330 lines of menu code. Full WAI-ARIA Menu Button keyboard
   nav, mobile 44px touch targets, design-token-only CSS, dark mode
   handled by the token cascade. Same aria-haspopup/role attributes as
   the inline implementation — existing E2E locators still work.

3. Replace inline style={{}} on the <tr> opacity transition with a CSS
   module class (.tableRowMutating), matching the prior fd73bca fix.

4. Surface API errors in handleRevertToPending, handleRevertToPaid, and
   handleStateConfirm. Menu-driven reverts show a section-level FormError
   banner (auto-dismiss 6s). State-confirm modal shows FormError inside
   the dialog. Two new i18n keys for network-error fallbacks; existing
   translateApiError() covers coded server errors.

Fixes #1413

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude translator (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com>

* fix(overflow-menu): canonical focus tokens and skip-disabled keyboard nav

UX-designer review on PR #1414 found two non-blocking nits in the new
OverflowMenu shared component:
- Default item focus ring switched from inset 2px var(--color-primary)
  to inset 3px var(--color-focus-ring) — the canonical menu-item ring
  used elsewhere in the codebase.
- Added missing .itemDanger:focus-visible rule with
  var(--color-focus-ring-danger) so destructive items have a
  distinguishable keyboard focus indicator.
- Arrow-key / Home / End keyboard handlers and the initial-focus query
  now use [role="menuitem"]:not(:disabled), so the cursor skips
  disabled items.

Refs #1413

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>

---------

Co-authored-by: Frank Steiler <frank@steiler.de>
Co-authored-by: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant