feat(invoice,budget): invoice deposits UI + deposit-aware budget rollups (#1404, #1405)#1407
Conversation
#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>
a71a025 to
634a421
Compare
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>
steilerDev
left a comment
There was a problem hiding this comment.
[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.
|
[product-architect] Review: APPROVED with medium findings for follow-up refinement. Verified
Medium findings (refinement-eligible, not blocking)
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. |
steilerDev
left a comment
There was a problem hiding this comment.
[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
Modalis 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, andstatus-in-progressfamilies menuButtonfocus-visible usesvar(--shadow-focus)correctly (onlymenuItemhas the inset variant)- Keyboard nav: ArrowDown/Up/Home/End/Escape implemented on both
DepositRowandDepositCard; trigger-to-first-item focus-on-open viauseEffect; 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-labelwith full translated string — correct prefers-reduced-motionguard on.conditionalField— correctrole="menu"/role="menuitem"structure — correctFormErrorused for all error display — correctformatCurrency/formatDatefrom formatters (not rawIntl) — correct- German translations complete including glossary entries for "Deposit" / "Final payment"
|
[product-owner] Verdict: APPROVEDI 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 #1404 — Detail-page UI (24 ACs)Layout / structure
Add / Edit / Delete / State toggles
i18n / glossary / accessibility / responsive
#1405 — Budget rollup integration (16 ACs)Aggregation invariants
Rollup path coverage
No regressions
Invariant / API shape
Tests
Non-blocking observations (follow-up worthy, not gating)
RecommendationAPPROVE. 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 The four observations above should be tracked as follow-up issues but are explicitly not blockers for merging this PR to |
|
🎉 This PR is included in version 2.6.0-beta.3 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
…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>
Summary
#1404 — Invoice deposits detail-page UI
Modal,Badge,FormError, andEmptyStatecomponents. 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 underinvoiceDetail.deposits.*in EN and DE; glossary updated: Deposit → Abschlagszahlung, Final payment → Schlusszahlung.#1405 — Deposit-aware budget rollups
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 extraLEFT 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.Fixes #1404
Fixes #1405
Test plan
depositAggregateUtils,budgetServiceFactory,budgetSourceService,budgetOverviewService)InvoiceDepositsSection,invoiceDepositsApi)invoice-deposits.spec.ts— desktop/tablet/mobile viewports)🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com