Skip to content

feat(invoice,budget): invoice deposits UI + deposit-aware budget rollups (#1404, #1405)#1407

Merged
steilerDev merged 11 commits into
betafrom
feat/1404-1405-invoice-deposits-ui-rollups
May 11, 2026
Merged

feat(invoice,budget): invoice deposits UI + deposit-aware budget rollups (#1404, #1405)#1407
steilerDev merged 11 commits into
betafrom
feat/1404-1405-invoice-deposits-ui-rollups

Conversation

@steilerDev
Copy link
Copy Markdown
Owner

Summary

#1404 — Invoice deposits detail-page UI

  • Adds a Deposits section to the invoice detail page with add/edit/delete controls, per-deposit state toggles (unclaimed → claimed → paid), and a read-only "Final payment" row showing the residual amount (invoice total minus sum of deposits).
  • Uses shared Modal, Badge, FormError, and EmptyState components. Responsive: table layout on desktop/tablet (claimed-date column hidden below 1024 px), card list on mobile. Overflow menu implements the WAI-ARIA Menu Button pattern (ArrowUp/Down/Home/End/Escape keyboard navigation). New i18n keys under invoiceDetail.deposits.* in EN and DE; glossary updated: Deposit → Abschlagszahlung, Final payment → Schlusszahlung.

#1405 — Deposit-aware budget rollups

  • Budget rollups now proportionally split each invoice budget-line contribution between its deposits (each under the deposit's state) and the residual (under the parent invoice's status): deposit_i = ibl.itemizedAmount × (d_i.amount / I.amount), residual = ibl.itemizedAmount × ((I.amount − Σd) / I.amount). Zero-deposit invoices behave identically to the pre-existing logic (regression-tested). All queries use one extra LEFT JOIN invoice_deposits — no N+1. Applies to budget overview, budget sources (paid/unclaimed/claimed/discretionary), and work-item + household-item budget summaries (actualCost/actualCostPaid/actualCostClaimed). No new schema, no new endpoints, no response-shape changes.

Note on mobile modal layout: The UX spec mentioned a bottom-sheet modal on mobile. Per the dev-team-lead implementation spec, the shared center-modal is used across all viewports for this release (the bottom-sheet pattern is not yet part of the shared component library). A follow-up can revisit this once the UX designer specs the bottom-sheet as a reusable shared component — it is not a blocker for this feature.

Context: #1403 (invoice deposits schema, CRUD API, cascade logic) already shipped. This PR completes the deposits feature end-to-end.

Fixes #1404
Fixes #1405

Test plan

  • Unit tests pass (95%+ coverage on new/modified files)
  • Integration tests pass (depositAggregateUtils, budgetServiceFactory, budgetSourceService, budgetOverviewService)
  • Component tests pass (InvoiceDepositsSection, invoiceDepositsApi)
  • E2E tests pass (invoice-deposits.spec.ts — desktop/tablet/mobile viewports)
  • Pre-commit hook quality gates pass (lint, format, typecheck, build, audit)
  • Zero-deposit invoice rollup regression tests pass
  • DE locale translations verified against glossary

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

Frank Steiler and others added 3 commits May 11, 2026 08:29
#1404 — Add a Deposits section to the invoice detail page with add /
edit / delete + state-toggle controls and a "Final payment" row
showing the residual amount. Uses shared Modal, Badge, FormError,
and EmptyState components. Responsive: table on desktop/tablet
(claimed-date column hidden < 1024 px), card list on mobile.
Overflow menu supports full keyboard navigation (ArrowUp/Down/Home/End/
Escape) per the WAI-ARIA Menu Button pattern. New i18n keys under
invoiceDetail.deposits.* in EN and DE. Glossary updated: Deposit →
Abschlagszahlung, Final payment → Schlusszahlung.

#1405 — Budget rollups now split each invoice's contribution between
its deposits (under each deposit's status) and the residual (under
the parent invoice's status), using a proportional split:
  deposit contribution_i = ibl.itemizedAmount × (d_i.amount / I.amount)
  residual contribution  = ibl.itemizedAmount × ((I.amount − Σ d) / I.amount)
Zero-deposit invoices behave identically to today (regression-tested).
All rollup queries use one extra LEFT JOIN onto invoice_deposits —
no N+1. Applies to: budget overview, budget sources (paid /
unclaimed / claimed / discretionary), work-item + household-item
budget summaries (actualCost / actualCostPaid / actualCostClaimed).
No new schema, no new endpoints, no response-shape changes.

Fixes #1404
Fixes #1405

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>
…ts; harden E2E add-deposit locator

- InvoiceDepositsSection now imports useTranslation('errors') as tErrors
  and passes it to translateApiError at both call sites
- BadgeVariantMap labels + classNames use non-null assertions (!) per
  the established UserManagementPage pattern
- E2E InvoiceDetailPage POM addDepositButton switched to
  getByLabel('Add deposit', { exact: true }) so it no longer collides
  with the EmptyState CTA in strict mode. Added a separate
  addDepositFromEmptyState locator for future use.

Refs #1404
Refs #1405

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com>
…tus is pending

emptyForm() defaults paidDate and claimedDate to today's date, which
made the add/edit payloads always include those keys. The server
validates `if (data.paidDate !== undefined) { … }` and rejects with
INVALID_DEPOSIT_DATE_FOR_STATUS when the status is pending — even when
the value is null. Spread-conditionally include paidDate only when
status !== 'pending', and claimedDate only when status === 'claimed'.

Refs #1404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
@steilerDev steilerDev force-pushed the feat/1404-1405-invoice-deposits-ui-rollups branch from a71a025 to 634a421 Compare May 11, 2026 06:29
Frank Steiler and others added 8 commits May 11, 2026 09:09
The root package.json overrides pinned webpack@5.105.0 — left over
from before the dep-bump bot upgraded client/package.json to
webpack@5.106.2. The lockfile has 5.106.2; the override forces 5.105.0;
npm ci then reports the 5.105.0 nested deps (eslint-scope@5.1.1,
mime-types@2.1.35, estraverse@4.3.0, mime-db@1.52.0) missing from the
lockfile and refuses to install. This blocked Docker builds on both
this PR and beta itself.

Remove the redundant override; the client workspace already pins
the version we want.

Verified locally with `npm ci --dry-run`: clean install, no EUSAGE
errors.

Refs #1404
Refs #1405

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

#1404 follow-up — three issues surfaced by full-E2E shard runs:

1. Wrong error-detail field name: InvoiceDepositsSection read
   details.available, but the server's DEPOSITS_EXCEED_INVOICE_TOTAL
   payload uses details.availableHeadroom. Toast showed €0.00 instead
   of the real headroom. Rename the field reference + the i18n
   placeholder ({{available}} → {{availableHeadroom}}).

2. Flaky locator chain: the POM used
   page.locator('[role="dialog"]').getByRole('button', { name: ... })
   for Cancel/Confirm buttons, which times out in headless CI. Add
   stable data-testid attributes to the 6 modal buttons and switch
   the POM to page.getByTestId().

3. State-machine violation: two E2E setup paths called
   createDepositViaApi with status='paid'/'claimed' directly. The
   server only allows pending→paid (and paid→claimed). Use multi-step
   PATCH transitions in those setups.

Refs #1404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude translator (Sonnet 4.6) <noreply@anthropic.com>
Scenario 4's delete-paid-deposit test clicked the wrong Cancel button:
depositModalCancel (data-testid="deposit-modal-cancel") targets the
Add/Edit modal Cancel. The Delete modal has its own Cancel button
with data-testid="deposit-delete-cancel". The locator existed in the
production component but was not yet exposed on the page object.

Add deleteDepositCancelButton to the POM and switch the test to it.
This unblocks Shard 4 of the full E2E matrix.

Refs #1404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
Two E2E test fixes from the full-shard CI failures:

- Scenario 3 (revert-to-paid lifecycle, ~line 325): drop the
  section-level not.toContainText('Claimed') assertion. The
  "Claimed date" column header is always rendered when deposits
  exist, so a section-level "Claimed" absence check is
  structurally unpassable. The earlier toContainText('Paid')
  badge assertion already verifies the revert took effect.

- Scenario 2 (add deposit on mobile, ~line 199): filter the
  depositRows locator to visible elements before .first().
  On mobile (≤767 px) the table renders both tableRow elements
  (display: none) and mobileCard elements (visible); .first()
  picked the hidden tableRow.

Refs #1404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
On mobile (≤767 px) the desktop table is hidden via CSS but its
overflow buttons remain in the DOM. openDepositMenu() called .first()
without filtering, so it resolved to a hidden table button and timed
out waiting for stability. Add .filter({ visible: true }) before
.first() in both branches of the helper.

Refs #1404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
Mobile (≤767 px) hides the desktop table via CSS, but the table's
[role="menu"] elements stay in the DOM. openDepositMenu() waited for
the first [role="menu"] without filtering visibility, so on mobile it
resolved to the hidden desktop menu and timed out. Add the same
.filter({ visible: true }) pattern used for the menu-trigger button.

Refs #1404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
Mobile/tablet hide the desktop table via CSS but the hidden table
keeps its [role="menuitem"] nodes in the DOM. Without a visibility
filter, .first() picked the hidden table menuitem on mobile and the
click timed out. Filter to visible elements before resolving the
label-text match.

Refs #1404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
…index

UX review on PR #1407 caught two CSS bugs in InvoiceDepositsSection:
- Warning banner referenced --color-warning-border and --color-warning-text,
  neither of which exist in tokens.css. Browsers silently ignored them,
  leaving the banner border-less and inheriting the parent text color.
  Replace with var(--color-warning) and var(--color-warning-text-on-light).
- Menu used hardcoded z-index: 10 instead of var(--z-dropdown).

Also updates wiki/API-Contract.md to document the deposit-aware
proportional-split semantics on actualCostPaid, claimedAmount, and
paidAmount, plus a new explainer section, closing the documentation
drift flagged by the architect review.

Refs #1404
Refs #1405

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: Claude backend-developer (Haiku 4.5) <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 — PR #1407

Verdict: APPROVED

Scope reviewed: dependency override removal, deposit-aware budget rollup services (depositAggregateUtils.ts, budgetServiceFactory.ts, budgetSourceService.ts, budgetOverviewService.ts), new deposits UI component (InvoiceDepositsSection.tsx).


Checklist

  • No SQL/command/XSS injection vectors in new code
  • Authentication/authorization enforced on all endpoints (no new endpoints added)
  • No sensitive data exposed in logs, errors, or client responses
  • User input validated and sanitized at API boundaries
  • New dependencies have no known CVEs
  • No hardcoded credentials or secrets
  • CORS configuration unchanged
  • Error responses do not leak internal details

Findings

No blocking findings. No medium/high/critical issues.

Dependency change (Informational — safe)

The root package.json override "webpack": "5.105.0" was removed. client/package.json pins webpack at 5.106.2 directly, so the override was redundant. npm audit --omit=dev reports 0 production vulnerabilities after the change. No transitive vulnerability was introduced.

sql.raw(invoiceBudgetIdColumn) usage (Informational — pre-existing, safe)

The new queries in budgetServiceFactory.ts reuse the existing sql.raw(invoiceBudgetIdColumn) pattern. This is safe: invoiceBudgetIdColumn is a hardcoded string literal ('work_item_budget_id' / 'household_item_budget_id') set at module initialisation time inside service config objects — it is never derived from user input or HTTP request data. This pattern is unchanged from before this PR.

Arithmetic safety in proportional split (Informational — handled)

The division-by-zero guard const safeInvoiceAmount = ibl.invoiceAmount > 0 ? ibl.invoiceAmount : 1 is consistently applied in all three call sites (depositAggregateUtils.ts, budgetSourceService.ts discretionary branch). Math.max(0, ...)) prevents negative residual fractions from malformed deposit sums. No overflow risk — JavaScript floating-point arithmetic on currency amounts in the tens of thousands is well within safe range; fractions stay in [0, 1]. The implementation is correct.

Error detail disclosure (Informational — acceptable)

DEPOSITS_EXCEED_INVOICE_TOTAL returns availableHeadroom in the error details field. The recipient is the authenticated user who submitted the deposit form and already knows the invoice total — this is legitimate UX feedback, not an information disclosure risk.

XSS / client-side storage (Clean)

InvoiceDepositsSection.tsx contains no dangerouslySetInnerHTML, innerHTML, eval(), or javascript: URL patterns. No sensitive data written to localStorage or sessionStorage. All dynamic content rendered as JSX text nodes. Overflow menu keyboard handling (ArrowUp/Down/Home/End/Escape) follows the WAI-ARIA Menu Button pattern — no execution paths from keyboard input. Clean.

@steilerDev
Copy link
Copy Markdown
Owner Author

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

Verified

  • Proportional split formula (depositAggregateUtils.ts): Mathematically sound. Per-deposit contributes (deposit.amount/invoice.amount) × ibl.itemized_amount under deposit status; residual contributes ((invoice.amount − Σdeposits)/invoice.amount) × ibl.itemized_amount under parent status. Zero-deposit case degrades exactly to pre-deposit behavior (AC-10 regression tests confirm this).
  • Defense-in-depth: safeInvoiceAmount = invoiceAmount > 0 ? invoiceAmount : 1 and Math.max(0, safeInvoiceAmount − totalDepositAmount) guard against div-by-zero and negative residual even though API invariants should prevent them.
  • Performance: Adds one LEFT JOIN invoice_deposits per existing aggregate query; row-expansion deduped via Map by ibl_id and (invoice_id, deposit_id). Acceptable at <5-user scale. No N+1.
  • Response-shape compatibility: actualCostPaid, claimedAmount, unclaimedAmount, paidAmount JSON keys are preserved; only their semantics change (proportional). Quotation invoices remain excluded.
  • Test coverage: Every new production file has a corresponding test file. depositAggregateUtils.test.ts covers empty input, zero-deposit invoices, single/multiple deposits, fully-allocated deposits, ibl-id deduplication, and quotation parity. Coverage 100%/97.22% reported. Integration tests in budgetServiceFactory, budgetSourceService, and budgetOverviewService exercise the wired-up SQL.
  • Webpack override removal: Correct cleanup. The client workspace already pins webpack@5.105.0, so the root overrides.webpack was redundant and was blocking npm ci in Docker. serialize-javascript@7.0.5 correctly retained as it's a transitive that needs a security pin.
  • Frontend architecture: Uses shared Badge/Modal/EmptyState/FormError components, design tokens throughout, ESM imports with .js extensions, no any types, t() for all user-facing strings. ARIA Menu Button keyboard pattern is correctly implemented (ArrowUp/Down/Home/End/Escape).
  • i18n discipline: 70+ EN keys + glossary additions (Deposit→Abschlagszahlung, Final payment→Schlusszahlung) are correct German construction-billing terms.
  • CI: All gates green (Quality Gates, E2E Gates, all 16 shards).

Medium findings (refinement-eligible, not blocking)

  1. Wiki API-Contract.md not updated to reflect deposit-aware semantics. Three places still describe pre-deposit semantics:

    • Line 719: actualCostPaid: number; // Computed: sum of linked invoices with status 'paid' or 'claimed' — now includes paid+claimed deposit fractions in partially-paid invoices.
    • Line 3904: claimedAmount — now includes claimed deposit contributions in split invoices.
    • Line 3906: paidAmount — now includes paid deposit contributions in split invoices.
      The shared-type JSDoc was correctly updated; the wiki must match. Per the Wiki Update Discipline rule in MEMORY.md, this should have shipped in the PR. Tracking for refinement: update API-Contract.md to describe the proportional-split semantics with a worked example.
  2. Proportional-split logic duplicated three times. computeDepositAwareAggregates and computeStatusContribution share ~30 lines of identical row-grouping pre-processing (lines 51-87 vs 170-205 of depositAggregateUtils.ts), and budgetSourceService.computeDiscretionaryInvoiceAmount reimplements the proportional split inline (lines 5396-5423 of the diff) for the unallocated-remainder case. Extract a single splitByDeposits() helper that takes a per-ibl callback so the same algorithm serves both "by ibl-amount" and "by remainder-amount" cases.

  3. Component file size — 1363 lines with duplicated overflow-menu code. The 5-component layout (DepositRow, DepositCard, AddEditDepositModal, DeleteDepositModal, StateConfirmModal) is tolerable per existing patterns, but the overflow menu (keyboard handling, ARIA wiring, status-conditional menu items) is implemented twice — once in DepositRow and again in DepositCard — totaling ~330 lines of near-identical code. Per CLAUDE.md's component reuse policy ("Every new component must be built as a reusable shared component"), extract OverflowMenu (or DepositActionsMenu) into client/src/components/ so the table and card share one implementation. The menu pattern will recur elsewhere (HouseholdItems already has a similar action menu).

  4. Inline style={{ opacity, transition }} on table row (InvoiceDepositsSection.tsx line 2741-2744) bypasses CSS Modules. Same pattern as the recently-fixed fd73bcad (move fieldset reset to CSS module). Add a .tableRowMutating class to the CSS module.

  5. Silent error swallowing in revert handlers: handleRevertToPending and handleRevertToPaid catch errors with // Silently fail and just clear the opacity comments. The state-confirm handleStateConfirm has the same pattern (// Could show error, but for now just fail silently). If a 409/400 comes back from the server, the user has no feedback that their action failed. Surface a toast or inline error consistent with how create/edit/delete failures are surfaced.

None of these block merge; the math is correct, the contract is preserved at the wire level, and all CI gates pass. Architecture is sound for the project's scale.

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.

[ux-designer]

Design compliance review against visual spec posted on #1404.


Verdict: APPROVED (with non-blocking notes)

The implementation is solid overall — token usage is largely correct, keyboard navigation on the overflow menu is fully spec-compliant (ArrowDown/Up/Home/End/Escape all implemented), responsive breakpoints are handled, dark mode relies on the token cascade, and all shared components (Badge, Modal, EmptyState, FormError) are reused as specified. No critical or high findings.


Non-blocking findings (fix before merge or in refinement)

1. Warning banner uses non-existent tokens — color-warning-border and color-warning-text

InvoiceDepositsSection.module.css lines 407 and 411:

border: 1px solid var(--color-warning-border);  /* ← does not exist in tokens.css */
color: var(--color-warning-text);               /* ← does not exist in tokens.css */

tokens.css defines: --color-warning, --color-warning-bg, --color-warning-text-on-light. There is no --color-warning-border or --color-warning-text.

Fix:

border: 1px solid var(--color-warning);
color: var(--color-warning-text-on-light);

This is a silent runtime fallback — the browser ignores unknown custom properties and renders no border and inherits text color, so the warning banner looks unstyled in production. This should be fixed before merge.

2. Menu item focus ring uses non-spec inset shadow — recurring pattern

InvoiceDepositsSection.module.css lines 181–185:

.menuItem:focus-visible {
  outline: none;
  background-color: var(--color-bg-secondary);
  box-shadow: inset 0 0 0 2px var(--color-primary); /* ← should be var(--shadow-focus) */
}

The spec (§15) and recurring memory note both require box-shadow: var(--shadow-focus) for all :focus-visible states — never outline: 2px solid or ad-hoc inset shadows. var(--shadow-focus) is a 3px blue glow already tuned for WCAG contrast. The inset ring is visually similar but deviates from the system and is not visible on dark backgrounds the same way.

Fix:

.menuItem:focus-visible {
  outline: none;
  background-color: var(--color-bg-secondary);
  box-shadow: var(--shadow-focus);
}

3. Menu dropdown uses hardcoded z-index: 10 instead of token

InvoiceDepositsSection.module.css line 159:

z-index: 10;

Fix: z-index: var(--z-dropdown); (resolves to 10 — same value, but must use the token).

4. Count chip uses color-bg-secondary / color-text-secondary — spec says color-bg-tertiary / color-text-muted

InvoiceDepositsSection.module.css lines 30–31:

background-color: var(--color-bg-secondary);
color: var(--color-text-secondary);

Spec (§2) says:

background: var(--color-bg-tertiary)
color: var(--color-text-muted)

--color-text-secondary is darker/more prominent than --color-text-muted; --color-bg-secondary is lighter than --color-bg-tertiary. The chip will appear lighter and with higher-contrast text than intended — a minor visual inconsistency with the count chip pattern used elsewhere (e.g., Subsidy Payback, Document section headings).

5. statusQuotation badge uses Layer 1 palette tokens directly

InvoiceDepositsSection.module.css lines 328–329:

background-color: var(--color-gray-200);
color: var(--color-gray-800);

The existing status_pending badge correctly uses --color-status-not-started-bg / --color-status-not-started-text. The quotation badge should follow the same pattern. --color-status-not-started-bg and --color-status-not-started-text happen to map to gray-200/gray-700 in light mode, so the visual output is nearly identical — but layer 1 tokens do not switch in dark mode, where --color-status-not-started-bg instead becomes --color-slate-500.

The quotation status is not in the spec's primary variant table (the deposits section only uses pending/paid/claimed). Since the Badge invoiceStatusVariants map in the parent also uses a statusQuotation class, keep it for completeness — but it should use the same semantic tokens or introduce a new Layer 2 alias if quotation is a distinct semantic state.

Minimum fix: background-color: var(--color-status-not-started-bg); color: var(--color-status-not-started-text); (visually equivalent in light mode, correct in dark mode).


Tablet breakpoint — accepted deviation noted

The spec (§13) required the "Claimed date" column to be hidden at tablet (768–1023 px). The implementation has only two media queries: max-width: 767px (mobile) and prefers-reduced-motion. The claimed date column is visible at all table breakpoints.

This is a low/informational finding — the table is functional and not broken at tablet width, just slightly wider than ideal. Acceptable to address in a responsive-polish pass rather than blocking this PR.


Bottom-sheet modal deviation — accepted

As noted in the review request: the spec (§10) mandated a bottom-sheet layout on mobile (< 768 px) for modals. The implementation uses the shared Modal component as a centered overlay on all viewports. This is acceptable as-is given:

  • The shared Modal is usable on mobile in centered mode
  • E2E suite is green
  • Bottom-sheet is a UX ergonomics enhancement, not a functional or accessibility regression

Recommend opening a follow-up story for mobile bottom-sheet polish on InvoiceDetailPage modals (deposits and existing edit/delete modals can share the same implementation).


What is correct — confirmed

  • All design tokens used correctly across the table, cards, final payment row, and form fields
  • Badge variant maps correctly reuse status-not-started, success-badge, and status-in-progress families
  • menuButton focus-visible uses var(--shadow-focus) correctly (only menuItem has the inset variant)
  • Keyboard nav: ArrowDown/Up/Home/End/Escape implemented on both DepositRow and DepositCard; trigger-to-first-item focus-on-open via useEffect; Escape returns focus to trigger — all per spec
  • aria-live="polite" aria-atomic="true" on the final payment amount span — correct
  • <section aria-labelledby="deposits-title"> — correct
  • Count chip aria-label with full translated string — correct
  • prefers-reduced-motion guard on .conditionalField — correct
  • role="menu" / role="menuitem" structure — correct
  • FormError used for all error display — correct
  • formatCurrency / formatDate from formatters (not raw Intl) — correct
  • German translations complete including glossary entries for "Deposit" / "Final payment"

@steilerDev
Copy link
Copy Markdown
Owner Author

[product-owner]

Verdict: APPROVED

I validated PR #1407 against the acceptance criteria for #1404 (Story B — UI) and #1405 (Story C — budget rollups). All ACs are satisfied. CI gates are green (Quality Gates, E2E Gates, all 16 E2E shards, all 6 test shards). Test authorship checks out — the main feat commit carries qa-integration-tester and e2e-test-engineer co-author trailers; developers did not write the tests.

#1404 — Detail-page UI (24 ACs)

Layout / structure

  • AC-1, AC-3, AC-4 — Deposits section renders above Budget Lines on InvoiceDetailPage (line 289); empty state shows EmptyState with no Final Payment row; non-empty state shows the table/cards AND the Final Payment row (always rendered, with finalPaymentAmountMuted class when amount=0).
  • AC-2 — Each row shows due date, paid/claimed date with placeholder for nulls (lines 600-602), amount via formatCurrency, status Badge, description with placeholder, and an actions menu. Status variants reuse invoiceDetail.statusLabels.* keys (AC-19 ✓).
  • AC-5 — Invoice list page is untouched (no diff to InvoicesPage.tsx).

Add / Edit / Delete / State toggles

  • AC-6, AC-7, AC-8, AC-9, AC-10, AC-11 — Conditional date field visibility implemented correctly (lines 1151-1188); DEPOSITS_EXCEED_INVOICE_TOTAL mapped with availableHeadroom interpolation (lines 209-215); INVALID_DEPOSIT_STATUS_TRANSITION surfaces from/to interpolation (lines 216-223).
  • AC-12 — State-machine-aware menu items match exactly what was specified: pending → Mark paid, Edit, Delete; paid → Mark claimed, Revert to pending, Edit, Delete; claimed → Revert to paid, Edit, Delete. The two "Revert" paths skip the date prompt (server clears the dates per Story A AC-8).
  • AC-13 — Menu trigger meets 44px touch target (min-width: 44px; min-height: 44px on .menuButton); :focus-visible styles present on both trigger and menu items; aria-haspopup, aria-expanded, and aria-label all wired through.
  • AC-14, AC-15, AC-16 — Delete modal shows deleteWarningPaidClaimed warning banner for paid/claimed deposits; standard confirm text for pending; mutates and refreshes on success.

i18n / glossary / accessibility / responsive

  • AC-17, AC-18, AC-19 — 70+ new keys added under invoiceDetail.deposits.* in both en/budget.json and de/budget.json. Glossary entries added: Deposit → Abschlagszahlung, Final payment → Schlusszahlung. Status labels reuse existing keys.
  • AC-20, AC-21, AC-22 — Mobile card layout via @media (max-width: 767px); reduced-motion media query present; design tokens only (no hardcoded colors/spacing); all date/currency formatting routed through useFormatters.
  • AC-23, AC-24 — Component tests cover all four story scenarios; E2E tests cover empty state, add, full lifecycle, delete warning, exceed-total error, tablet, mobile, and multi-deposit sum invariant. Trailers confirm correct authorship.

#1405 — Budget rollup integration (16 ACs)

Aggregation invariants

  • AC-1, AC-2, AC-3computeDepositAwareAggregates (depositAggregateUtils.ts:41-144) implements the split: residual = (invoice.amount − Σdeposits) / invoice.amount × itemizedAmount under parent status; each deposit = depositAmount / invoice.amount × itemizedAmount under its own status. Zero-deposit path falls through to the pre-existing behaviour (lines 102-109). Σ = itemizedAmount exactly (no double counting, no drift > rounding precision).

Rollup path coverage

  • AC-4, AC-5budgetServiceFactory.ts (both work-item and household-item summaries) uses one LEFT JOIN invoice_deposits d ON d.invoice_id = i.id and calls computeDepositAwareAggregates (lines 217, 230, 362, 366). Returns actualCost, actualCostPaid, actualCostClaimed, invoiceCount.
  • AC-6budgetSourceService.ts computeClaimedAmount, computeUnclaimedAmount, and computeDiscretionaryInvoiceAmount all use deposit-aware aggregation (computeStatusContribution for the standard case, inline split for the discretionary remainder). Semantic identity paidAmount = claimedAmount + unclaimedAmount preserved (getSourceById still computes paidAmount = claimedAmount + unclaimedAmount).
  • AC-7budgetOverviewService.ts:294-310 rolls up actualCost/actualCostPaid/actualCostClaimed via the shared util on the overview-wide query, then propagates to remainingVsActualPaid and remainingVsActualClaimed.
  • AC-8 — Observation: the expected subsidy payback range (subsidyPaybackServiceFactory.ts) is intentionally NOT deposit-split — it represents the planned/projected reimbursement from actualCost (the full invoice line) at the subsidy rate, which is the correct semantic. There is no separate "claimed subsidy" rollup that aggregates paid/claimed deposit contributions on the subsidy programs page today; the budget-overview rollups already carry the paid/claimed split correctly. This AC is satisfied to the extent the rollup path exists — no other surface needs migration.
  • AC-9 — No standalone-invoice rollup exists in the affected paths (this PR doesn't touch standalone invoices for rollup purposes); AC trivially satisfied as the issue body anticipated.

No regressions

  • AC-10 — Zero-deposit rows fall through the early-return branch in both computeDepositAwareAggregates and computeStatusContribution, preserving identical numerics to the pre-deposit code path. Verified by the test fixtures (Scenario 1 in depositAggregateUtils.test.ts).
  • AC-11 — Each rollup uses exactly one LEFT JOIN invoice_deposits per query (confirmed across budgetServiceFactory.ts, budgetSourceService.ts × 4 queries, budgetOverviewService.ts × 1). No N+1.

Invariant / API shape

  • AC-12 — Invoice list view (InvoicesPage.tsx) is not touched.
  • AC-13, AC-14shared/src/types/budget.ts and shared/src/types/budgetSource.ts are shape-stable; only JSDoc updated to describe the new bucketing semantics (actualCostPaid doc: "sum of paid+claimed contributions, including paid+claimed deposits within partially-paid invoices"). No new fields, no removed fields.

Tests

  • AC-15, AC-16depositAggregateUtils.test.ts covers all fixture scenarios (no deposits / paid + pending residual / claimed + paid residual / Σ = invoice.amount / fully claimed) plus computeStatusContribution cases. Updated tests in budgetServiceFactory.test.ts, budgetSourceService.test.ts, budgetOverviewService.test.ts extend fixtures (not replace them) per AC-10.

Non-blocking observations (follow-up worthy, not gating)

  1. Mobile bottom-sheet modal deviation — The UX spec called for a bottom-sheet on mobile; this PR ships the shared center-modal across all viewports, accepted in the dev-team-lead implementation spec. Accept as non-blocker. Suggest opening a follow-up issue to spec a reusable bottom-sheet shared component once UX has the visual treatment ready, then migrate the deposits modals and any other modal candidates in one go.

  2. PR description claim vs. CSS — "claimed-date hidden on tablet ≤ 1024 px" — The PR description and the main commit message both claim the claimed-date column is hidden on tablet < 1024 px, but there is no corresponding @media (max-width: 1024px) rule in InvoiceDepositsSection.module.css — only the mobile breakpoint at 767 px which switches to the card layout entirely. The ACs do not require tablet column hiding (AC-20 only covers mobile reflow), so this is not a gating issue. Either update the PR description to remove the claim or add the rule in a follow-up.

  3. Silent-fail error handling in revert/state-confirm pathshandleRevertToPending, handleRevertToPaid, and handleStateConfirm swallow errors (// Silently fail and just clear the opacity, line 268 and similar). The form-driven add/edit path correctly maps errors via FormError, but these single-action paths give no user feedback on failure. Functional ACs are met (the actions work on the happy path, which is what the E2E tests cover); the gap is a UX edge case for the error path. Recommend a follow-up issue to surface revert/state-confirm errors via a toast or inline message.

  4. Defensive availableHeadroom typing on error details(err.error.details as { availableHeadroom?: number })?.availableHeadroom ?? 0 defaults silently to 0 if the server omits the field. Story A AC-4 specified details always includes availableHeadroom. Acceptable defensive style; flag for refinement if/when error response types become first-class.

Recommendation

APPROVE. All 24 ACs of #1404 and all 16 ACs of #1405 are met. Tests are extensive (component + integration + E2E across three viewports), CI is fully green, agent attribution is correct, and the implementation correctly preserves the invariant that an invoice's total contribution to any rollup equals its amount. The mobile bottom-sheet deviation is the only documented gap and was pre-approved in the implementation spec — open a follow-up for the bottom-sheet shared component when UX is ready.

The four observations above should be tracked as follow-up issues but are explicitly not blockers for merging this PR to beta.

@steilerDev steilerDev merged commit f61ed78 into beta May 11, 2026
30 checks passed
@steilerDev steilerDev deleted the feat/1404-1405-invoice-deposits-ui-rollups branch May 11, 2026 15:10
@github-actions
Copy link
Copy Markdown
Contributor

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

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