Skip to content

release: promote beta to main (invoice deposits + cleanups)#1415

Open
steilerDev wants to merge 18 commits into
mainfrom
beta
Open

release: promote beta to main (invoice deposits + cleanups)#1415
steilerDev wants to merge 18 commits into
mainfrom
beta

Conversation

@steilerDev
Copy link
Copy Markdown
Owner

Release Summary

Promotes the Invoice Deposits feature to stable, along with a budget-line form unification, regular dependency bumps, and a follow-up refactor closing the architect's tech-debt findings from the deposits review cycle.

Changes

Features

Fixes

Chores / Refactoring

Change Inventory

Backend (server/, shared/)

  • server/src/db/migrations/0032_invoice_deposits.sql (new)
  • server/src/db/schema.tsinvoiceDeposits Drizzle table
  • server/src/routes/invoiceDeposits.ts (new) + tests
  • server/src/services/invoiceDepositService.ts (new) + tests
  • server/src/services/invoiceService.ts + tests — deposit embedding on detail, deposit-aware status breakdown summary, list-endpoint payload optimisation
  • server/src/services/budgetOverviewService.ts + tests — step 8 deposit-aware aggregate
  • server/src/services/budgetSourceService.ts + tests — claimed/unclaimed/discretionary now deposit-aware
  • server/src/services/shared/budgetServiceFactory.ts + tests — getInvoiceAggregates/resolveRelationsBatch deposit-aware
  • server/src/services/shared/depositAggregateUtils.ts (new) + tests — splitByDeposits, computeDepositAwareAggregates, computeStatusContribution, aggregateInvoiceStatusBreakdown
  • server/src/services/diaryAutoEventService.ts + tests — onDepositStatusChanged
  • server/src/errors/AppError.tsDepositsExceedInvoiceTotalError, InvalidDepositStatusTransitionError, InvalidDepositDateForStatusError
  • server/src/app.ts — deposit route registration
  • shared/src/types/invoice.tsInvoiceDeposit, InvoiceDepositStatus, CreateDepositRequest, UpdateDepositRequest, Invoice.deposits/Invoice.finalPaymentAmount
  • shared/src/types/budget.ts + shared/src/types/budgetSource.ts — JSDoc clarifying deposit-aware split semantics
  • shared/src/types/errors.ts — 3 new error codes

Frontend (client/)

  • client/src/components/OverflowMenu/ (new) — shared component (Tsx + CSS + index)
  • client/src/lib/invoiceDepositsApi.ts (new) + tests — typed API client for deposits CRUD
  • client/src/pages/InvoiceDetailPage/InvoiceDepositsSection.tsx (new) + tests — section with table on desktop/tablet, card list on mobile, add/edit/delete + state-toggle controls, Final-payment row, error surfacing
  • client/src/pages/InvoiceDetailPage/InvoiceDepositsSection.module.css (new)
  • client/src/pages/InvoiceDetailPage/InvoiceBudgetLinesSection.tsx + CSS + tests — unified to consume the shared BudgetLineForm
  • client/src/pages/InvoiceDetailPage/InvoiceDetailPage.tsx + tests — wired in the deposits section
  • client/src/i18n/en/budget.json — deposit-related strings + 2 network-error fallbacks
  • client/src/i18n/de/budget.json — DE mirror with glossary terms Abschlagszahlung / Schlusszahlung
  • client/src/i18n/glossary.json — new terms Deposit, Final payment
  • Existing test fixtures updated to include deposits: [] / finalPaymentAmount on Invoice mocks

E2E Tests (e2e/)

  • e2e/tests/invoices/invoice-deposits.spec.ts (new) — 11 tests covering deposit lifecycle, Final-payment row, mobile card layout, error banner
  • e2e/tests/invoices/invoice-budget-line-create-and-link.spec.ts — covers unified budget-line creation flow
  • e2e/pages/InvoiceDetailPage.ts — extended POM (deposit locators, add-from-empty-state, modal cancel/save/delete-confirm/state-confirm by data-testid)

Build / Config / Wiki

  • package.json + package-lock.json — dep-bump fan-out; dropped stale webpack: 5.105.0 override that was blocking Docker npm ci after the bumps
  • client/package.json, server/package.json, docs/package.json — version bumps
  • .github/workflows/ci.yml, .github/workflows/release.yml — github-actions group bump
  • wiki/API-Contract.md — deposit endpoints, Invoice shape with deposits/finalPaymentAmount, deposit-aware aggregation explainer, new error codes
  • wiki/Schema.mdinvoice_deposits table

Manual Validation Checklist

Walk through each item against the beta image (or PR-specific image).

  • Add a deposit — open an invoice (any status), click "Add deposit", fill amount + due date, submit. Row appears with Pending badge; Final-payment row shows total − deposit.
  • State machine — open the deposit menu (⋮), Mark paid → Mark claimed → Revert to paid → Revert to pending. Each transition updates the badge and dates correctly.
  • Sum invariant — try adding a deposit whose amount would push Σ deposits > total. The form shows an error with available headroom.
  • Delete a paid/claimed deposit — confirmation modal shows a warning banner explaining the budget contribution will be removed.
  • Empty state — invoice with no deposits shows the EmptyState component with "Add deposit" CTA. No Final-payment row.
  • Mobile layout — viewport ≤ 767 px: deposits render as cards instead of a table. Final-payment row stays visible. Overflow menu items remain reachable.
  • Budget overview reflects splits — add a pending deposit of €200 to a quotation invoice of €1000. The InvoicesPage header summary should now show €800 under Quotation and €200 under Pending (instead of the old €1000 / €0). Budget overview totals and the Dashboard pipeline card should reflect the same split.
  • Invoice list rows unchanged/budget/invoices rows still show the parent invoice's full amount in the amount column (per Invoice deposits: detail-page UI (Deposits section, Final payment row, add/edit/delete + state toggles) #1404 AC-5).
  • Revert error feedback — if the server rejects a revert (e.g., manual cURL while UI open), the section shows a transient error banner that auto-dismisses after ~6 s.
  • Unified budget-line creation — open an invoice, "Create Budget Line": the form is the shared BudgetLineForm (matches the work-item and household-item create flows).
  • Spot-check DE locale — switch UI to German; deposit terms render as "Abschlagszahlung" / "Schlusszahlung" consistently.

Testing

  • DockerHub beta image: docker pull steilerdev/cornerstone:beta
  • PR-specific image: docker pull steilerdev/cornerstone:pr-<PR-NUMBER> (replace placeholder after PR creation)

steilerDev and others added 15 commits May 10, 2026 19:29
* feat(invoice): unify budget-line creation with BudgetLineForm component

- Replace slim 4-field form with reusable BudgetLineForm component in picker Step 2
- Add vendor fetch to showCreateBudgetLineForm (vendors now fetched alongside categories/sources)
- Implement complete VAT math following useBudgetSection.handleSaveBudgetLine pattern:
  - Direct mode: plannedAmount *= (includesVat ? 1 : 1.19), rounded to 2 decimals
  - Unit mode: plannedAmount = qty * price (no VAT multiplier)
- Auto-link newly-created budget line to invoice using newBudgetLine.plannedAmount
- Replace createFormData state with rich BudgetLineFormState
- Handle link errors (ITEMIZED_SUM_EXCEEDS_INVOICE, BUDGET_LINE_ALREADY_LINKED):
  - Transition back to existing-line list with error banner
  - New line shows as unlinked in the list
- Add focus management: focus to #budget-description on form open, back to button on cancel
- Add fieldset/visually-hidden legend for screen reader context
- Update CSS: .createBudgetLineForm now has --color-bg-primary bg, no padding (BudgetLineForm.container owns it)
- Remove .createFormTitle; add .srOnly utility class
- Add two i18n keys (English only) to budget.json under invoiceDetail.budgetLines
- Conditional rendering: hide existing-line list when create form is shown
- Add createBudgetLineButtonRef for focus restoration

Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>

* test(invoice): add tests and translations for budget-line auto-link (#1401)

- Add 14 unit-test scenarios covering vendor fetch, VAT math, create+link sequence,
  link error transitions (ITEMIZED_SUM_EXCEEDS_INVOICE / BUDGET_LINE_ALREADY_LINKED),
  cancel, and regression on select-existing flow
- Mock fetchVendors and BudgetLineForm at the module boundary for ESM tests
- Add Playwright E2E scenarios: happy path (unit + direct pricing), non-empty list,
  link-exceeds-invoice error, mobile responsive smoke, Escape-key close
- Extend InvoiceDetailPage POM with budget-line picker and create-form locators
- Add German translations for the two new budget.invoiceDetail.budgetLines keys
  using the glossary term "Budgetposition"

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>
Co-Authored-By: Claude translator (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>

* fix(e2e): correct invoice budget-line auto-link spec assertions (#1401)

- Remove toContainText('Roof materials') on budgetSection in Scenario 1 —
  the description is inside a collapsed InvoiceGroup accordion; the
  invoiceLink badge assertion already proves the link
- Replace fill('100') with click() + pressSequentially('100') in the
  mobile Scenario 4 — fill() does not fire React onChange reliably on
  the mobile viewport for number inputs

Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com>

* fix(e2e): scroll submit button into view in mobile scenario (#1401)

Playwright's auto-scroll fails inside the picker modal (overflow: hidden
on the modal container), so the submit button stayed outside the viewport
on the mobile run and click() timed out. Explicit scrollIntoViewIfNeeded
scrolls the element within its scrollable ancestor.

Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com>

* fix(invoice,e2e): mobile modal scroll + locale-independent picker locators (#1401)

- Make .modalBody scrollable on mobile so the rich BudgetLineForm can be
  used at viewport widths < 768px (the form is now taller than the slim
  one it replaced)
- Convert picker submit/unit-mode/cancel POM locators to structural
  selectors so the spec is robust to German locale state leaked by the
  i18n test suite

Fixes #1401

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>
Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>

* style(invoice): move fieldset reset from inline style to CSS module (#1401)

Addresses non-blocking nit from product-architect and ux-designer reviews.

Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>

---------

Co-authored-by: Frank Steiler <frank@steiler.de>
Co-authored-by: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
#1406)

* feat(invoice): add deposit support (schema, CRUD API, and cascade)

Adds invoice_deposits table and CRUD endpoints under both
/api/invoices/:invoiceId/deposits and the vendor-scoped variant.
Each deposit has its own status (pending → paid → claimed) and
contributes to budget rollups based on its own state, while the
parent invoice contributes its residual (final payment) amount
under its own status.

State machine enforced server-side: pending → paid, paid → claimed,
paid → pending (correction), claimed → paid (correction). Disallowed
transitions return INVALID_DEPOSIT_STATUS_TRANSITION (400). Σ deposit
amounts ≤ invoice amount enforced as DEPOSITS_EXCEED_INVOICE_TOTAL.

Read-check-write is atomic via drizzle's db.transaction((tx) => {})
to prevent sum-invariant races. Diary auto-events fire only on
transitions into paid/claimed, reusing the invoice_status entry type.

GET /api/invoices/:id now embeds deposits + finalPaymentAmount; list
endpoints intentionally return empty deposits and finalPaymentAmount =
invoice.amount for payload optimisation.

Fixes #1403

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 qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>

* test(invoice): add deposits + finalPaymentAmount to existing Invoice mocks

Existing client-side test fixtures construct Invoice objects literally;
adding the new required fields on the shared Invoice interface broke
their typecheck. Patch the factories (InvoiceLinkModal,
HouseholdItemDetailPage.budget) and individual literals (others) so
existing tests continue to compile under the updated Invoice shape.

Production code is unaffected — backend already returns deposits: []
and finalPaymentAmount = invoice.amount for invoices with no deposits.

Refs #1403

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>

* fix(invoice): finalPaymentAmount sums all deposits; sort by dueDate

Two AC-10 violations flagged in PR review:

1. finalPaymentAmount filtered deposits to status='claimed' only, but
   AC-10 (and the user requirement) specifies the un-itemized residual
   = invoice.amount - Σ ALL deposits.amount, regardless of status.
   The claimed-only semantic would double-count pending/paid deposits
   in the #1405 budget rollup.

2. Deposit ordering was inconsistent: toInvoice()'s embedded fetch had
   no orderBy at all; listDepositsForInvoice ordered by createdAt only.
   AC-10 specifies dueDate ASC, createdAt ASC in both paths.

Updates the three tests that hardcoded the wrong claimed-only semantic
and rewrites scenario 26 to create deposits with out-of-order dueDate
so the new ordering is actually exercised (plus a tie-breaker case).

Refs #1403

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 qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>

* test(invoice): fix scenario 26b double-setup collision on users.email UNIQUE

The test called setup() twice in the same body, causing the second INSERT
into users to fail with a UNIQUE constraint violation on user@example.com.
Replace the second setup() call with direct createTestVendor/createTestInvoice
helpers so the fresh invoice is created without inserting a duplicate user.

Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>

* docs(invoice): correct stale JSDoc on Invoice.finalPaymentAmount

Comment used to read "minus sum of claimed deposits" — describing the
pre-fix behaviour. After the round-2 fix, finalPaymentAmount subtracts
all deposit amounts regardless of status (per AC-10). Update the JSDoc
to match.

Refs #1403

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: Frank Steiler <frank@steiler.de>
Co-authored-by: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Bumps the github-actions group with 2 updates: [actions/cache](https://github.com/actions/cache) and [github/codeql-action](https://github.com/github/codeql-action).


Updates `actions/cache` from 5.0.4 to 5.0.5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](actions/cache@6682284...27d5ce7)

Updates `github/codeql-action` from 4.35.2 to 4.35.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@95e58e9...e46ed2c)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action
  dependency-version: 4.35.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the dev-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [eslint](https://github.com/eslint/eslint) | `10.2.0` | `10.3.0` |
| [stylelint](https://github.com/stylelint/stylelint) | `17.8.0` | `17.9.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.58.2` | `8.59.1` |
| [webpack](https://github.com/webpack/webpack) | `5.105.0` | `5.106.2` |
| [@docusaurus/core](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus) | `3.10.0` | `3.10.1` |
| [@docusaurus/preset-classic](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-preset-classic) | `3.10.0` | `3.10.1` |


Updates `eslint` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.2.0...v10.3.0)

Updates `stylelint` from 17.8.0 to 17.9.1
- [Release notes](https://github.com/stylelint/stylelint/releases)
- [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md)
- [Commits](stylelint/stylelint@17.8.0...17.9.1)

Updates `typescript-eslint` from 8.58.2 to 8.59.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/typescript-eslint)

Updates `webpack` from 5.105.0 to 5.106.2
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](webpack/webpack@v5.105.0...v5.106.2)

Updates `@docusaurus/core` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus)

Updates `@docusaurus/preset-classic` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-preset-classic)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: stylelint
  dependency-version: 17.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: webpack
  dependency-version: 5.106.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@docusaurus/core"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@docusaurus/preset-classic"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the prod-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@fastify/static](https://github.com/fastify/fastify-static) | `9.1.1` | `9.1.3` |
| [openid-client](https://github.com/panva/openid-client) | `6.8.3` | `6.8.4` |
| [i18next](https://github.com/i18next/i18next) | `26.0.5` | `26.0.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.4` | `17.0.6` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.1` | `7.14.2` |


Updates `@fastify/static` from 9.1.1 to 9.1.3
- [Release notes](https://github.com/fastify/fastify-static/releases)
- [Commits](fastify/fastify-static@v9.1.1...v9.1.3)

Updates `openid-client` from 6.8.3 to 6.8.4
- [Release notes](https://github.com/panva/openid-client/releases)
- [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md)
- [Commits](panva/openid-client@v6.8.3...v6.8.4)

Updates `i18next` from 26.0.5 to 26.0.8
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/i18next@v26.0.5...v26.0.8)

Updates `react-i18next` from 17.0.4 to 17.0.6
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/react-i18next@v17.0.4...v17.0.6)

Updates `react-router-dom` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.2/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: "@fastify/static"
  dependency-version: 9.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-dependencies
- dependency-name: openid-client
  dependency-version: 6.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-dependencies
- dependency-name: i18next
  dependency-version: 26.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-dependencies
- dependency-name: react-i18next
  dependency-version: 17.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-dependencies
- dependency-name: react-router-dom
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](fastify/fast-uri@v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ups (#1404, #1405) (#1407)

* feat(invoice,budget): invoice deposits UI + deposit-aware budget rollups

#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>

* fix(invoice): pass tErrors to translateApiError and Badge label asserts; 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>

* fix(invoice): omit paidDate/claimedDate from deposit payload when status 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>

* build(deps): drop stale webpack override blocking npm ci

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>

* fix(invoice): correct availableHeadroom field name and harden E2E locators

#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>

* test(invoice): wire deleteDepositCancelButton locator to delete modal

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>

* test(invoice): narrow over-broad locators in Scenarios 3 and 2

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>

* test(invoice): filter openDepositMenu locator to visible buttons

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>

* test(invoice): filter [role=menu] waitFor to visible elements

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>

* test(invoice): filter clickDepositMenuItem to visible menuitems

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>

* fix(invoice): use valid CSS tokens for warning banner and dropdown z-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>

---------

Co-authored-by: Frank Steiler <frank@steiler.de>
Co-authored-by: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
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: Frank Steiler <frank@steiler.de>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…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>
@steilerDev
Copy link
Copy Markdown
Owner Author

Detailed Validation Walkthrough

Step-by-step instructions to spot-check each major change against the beta image.

Run the beta image locally

docker pull steilerdev/cornerstone:beta
docker run -p 3000:3000 -v cornerstone-data:/app/data steilerdev/cornerstone:beta

Open http://localhost:3000 and log in.


1. Invoice deposits — basic flow

  1. Navigate to Budget → Invoices, open any invoice (or create a new quotation invoice for vendor X with amount €1000).
  2. Scroll to the Deposits section (between Invoice Details and Budget Lines).
  3. Click Add deposit. Enter amount=200, dueDate=<any>. Submit.
  4. Verify:
    • The deposit row appears with a Pending badge.
    • The Final payment row at the bottom shows €800 (= total − deposit).
    • The invoice's headline amount stays at €1000 (unchanged).

2. Invoice deposits — state machine

  1. From the deposit row's overflow menu (⋮), click Mark paid… → enter today's date → confirm.
  2. The badge changes to Paid; a paid-date column shows today's date.
  3. From the menu, click Mark claimed… → confirm. Badge → Claimed; claimed-date column appears.
  4. From the menu, click Revert to paid (no prompt). Badge → Paid; claimed-date clears.
  5. From the menu, click Revert to pending (no prompt). Badge → Pending; both date columns clear.

3. Sum invariant + error feedback

  1. With the deposit at €200 (Pending), click Add deposit again. Try amount=900.
  2. The form shows an error like "Deposit amount exceeds invoice total. Available headroom: €800".
  3. Adjust to amount=800, submit. The second deposit is created; Final payment now €0 (muted style).

4. Delete with warning

  1. Open the overflow menu on a paid or claimed deposit, click Delete.
  2. Confirmation modal renders with an orange warning banner explaining the budget contribution will be removed.
  3. Cancel — deposit stays. Re-open, confirm delete — row disappears and Final-payment updates.

5. Budget overview deposit-aware split (#1405 + #1412)

  1. Create a new vendor + invoice with amount=1000, status=quotation.
  2. Add a pending deposit of €200 on this invoice.
  3. Visit Budget → Invoices — the page header should show:
    • Quotation card: €800 contribution (the residual) + count of 1
    • Pending card: €200 contribution + count of 0 (deposits don't increment counts)
  4. Mark the deposit paid → header rebalances: Quotation €800, Paid card €200.
  5. Visit Dashboard — the InvoicePipelineCard reflects the same splits.

6. Budget overview — work-item / household-item rollups

  1. If invoices in the dataset are itemized to budget lines, open Budget → Overview and verify per-source / per-category aggregates show the per-deposit split (each deposit contributes under its state; residual under the parent's state). Zero-deposit invoices behave identically to v2.5.0 (regression-safe).

7. Mobile layout

  1. Set the browser viewport to ≤ 767 px (DevTools mobile preview).
  2. Open an invoice with at least one deposit.
  3. Deposits render as cards instead of a table. Final-payment row stays visible.
  4. The overflow menu (⋮) on a card opens; all actions remain reachable and the 44 px touch targets are usable.

8. Invoice list row regression check

  1. Visit Budget → Invoices. Each row's amount column should show the invoice's full amount (e.g., €1000), NOT the residual. This was an explicit AC of Invoice deposits: detail-page UI (Deposits section, Final payment row, add/edit/delete + state toggles) #1404 (list rows unchanged).

9. Empty state

  1. Find an invoice with zero deposits (or create one).
  2. The Deposits section shows the EmptyState with "Add deposit" CTA.
  3. No Final-payment row is rendered.

10. Revert error feedback (#1413)

This is harder to trigger without forcing a server error. If you want to validate:

  1. Open an invoice with a paid deposit.
  2. Either temporarily stop the server, OR craft an inconsistent state, then click "Revert to pending" from the menu.
  3. A transient error banner appears at the section top. It auto-dismisses after ~6 seconds.

11. Budget-line creation form (#1402)

  1. Open an invoice → click Create Budget Line.
  2. The picker modal opens. Pick a work item or household item, then click "Create Budget Line" in the empty-state.
  3. Verify the form is the shared BudgetLineForm — same fields, mode toggle (Direct/Unit), submit button as a single <button type="submit">, consistent with the work-item-create and household-item-create flows.

12. German locale

  1. Switch the UI to German (profile / language picker).
  2. Verify deposit terms render consistently: Abschlagszahlung (Deposit) and Schlusszahlung (Final payment).
  3. New error messages "Netzwerkfehler – Status der Abschlagszahlung konnte nicht zurückgesetzt werden" / "...aktualisiert werden..." appear when applicable.

13. Dark mode

  1. Toggle dark mode.
  2. Spot-check the Deposits section: status badges, overflow menu, Final-payment row, warning banner — all should pick up dark-mode tokens correctly.

Rollback

If a critical issue surfaces after promotion, the standard rollback path is:

  1. Revert the merge commit on main via gh pr revert.
  2. Re-apply the revert back to beta to keep branches in sync.
  3. The DockerHub latest and 1.x tags will rebuild from the reverted state.

The invoice_deposits table is additive only — deletion is not required on rollback. Existing invoices behave identically to v2.5.0 (zero-deposit case is regression-tested).

@steilerDev steilerDev closed this May 12, 2026
@steilerDev steilerDev reopened this May 12, 2026
steilerDev pushed a commit that referenced this pull request May 12, 2026
Release-cycle housekeeping. Bumps glossary metadata so beta gets a
fresh HEAD SHA, which unblocks the promotion PR #1415 — the existing
beta HEAD is an auto-fix commit that skipped CI, leaving the
promotion PR without associated CI checks.

No glossary terms changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude translator (Sonnet 4.6) <noreply@anthropic.com>
Release-cycle housekeeping. Bumps glossary metadata so beta gets a
fresh HEAD SHA, which unblocks the promotion PR #1415 — the existing
beta HEAD is an auto-fix commit that skipped CI, leaving the
promotion PR without associated CI checks.

No glossary terms changed.

Co-authored-by: Frank Steiler <frank@steiler.de>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

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

The release is available on GitHub release

Your semantic-release bot 📦🚀

`npm audit fix` removes two stale transitive lockfile entries that are
shadowed by the root serialize-javascript override (serialize-javascript
and randombytes nested under @docusaurus/bundler). Pre-applying this
in a regular commit so the auto-fix bot won't push another no-CI
commit after the next beta merge, which has been blocking CI
association on promotion PR #1415.

No functional changes.

Co-authored-by: Frank Steiler <frank@steiler.de>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

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

The release is available on GitHub release

Your semantic-release bot 📦🚀

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