Skip to content

feat(reports): add bank report wizard with client-side PDF pipeline - #1887

Merged
steilerDev merged 5 commits into
betafrom
feat/1879-report-wizard
Jul 30, 2026
Merged

feat(reports): add bank report wizard with client-side PDF pipeline#1887
steilerDev merged 5 commits into
betafrom
feat/1879-report-wizard

Conversation

@steilerDev

Copy link
Copy Markdown
Owner

Summary

  • Adds the bank report wizard page and its 4-step flow (use case, source selection, preview, options) under /budget/reports
  • Client-side PDF pipeline (pdfmake + pdf-lib) generates a cover letter, a budget/invoice overview table, and embeds/merges linked Paperless documents into one report PDF
  • Mark-claimed flow flags included invoices once a report is generated, and a "Generate report" entry is added to the budget source row overflow menu
  • New Reports tab is wired into the shared budget SubNav (BudgetOverviewPage, InvoicesPage, BudgetSourcesPage, SubsidyProgramsPage now share a BUDGET_TABS constant)
  • New pinned exact-version dependencies: pdfmake and pdf-lib in client/package.json

Fixes #1879
Fixes #1886

Test plan

  • Unit tests pass (95%+ coverage) — new tests for WizardStepper, ReportInvoiceList, ReportPdfPreview, reportPdf/* pipeline modules, sourceReportsApi, ReportWizardPage + step components, budgetTabs, paperlessApi.uploadPaperlessDocument
  • Integration tests pass — sourceReportService M2 (batched document lookup) and paperless route M3 (malformed taskId → 502) extensions
  • E2E tests pass — e2e/tests/budget/reportWizard.spec.ts with new ReportWizardPage POM
  • CI Quality Gates pass (typecheck, tests, build, audit)

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 translator (Sonnet 4.5) 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

Adds the bank report wizard: a 4-step flow (use case, source selection,
preview, options) that assembles a claim/status report entirely client-side
using a pdfmake + pdf-lib pipeline (cover letter generation, budget/invoice
overview table, and linked document embedding/merging), plus a mark-claimed
action to flag included invoices. Adds a "Generate report" entry to the
budget source row overflow menu and wires up the shared Reports tab across
the budget pages.

Fixes #1879
Fixes #1886

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 translator (Sonnet 4.5) <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>
@steilerDev

Copy link
Copy Markdown
Owner Author

[security-engineer] Security review of PR #1887 (feat/1879-report-wizard → beta, Story #1879).

Scope covered: new production dependencies (pdfmake@0.3.11, pdf-lib@1.17.1, @types/pdfmake@0.3.3 dev), the client-side PDF generation pipeline (client/src/lib/reportPdf/), blob/object-URL lifecycle in ReportWizardPage.tsx/ReportPdfPreview.tsx, the Paperless upload path (uploadPaperlessDocument), and the household-address/source-contact fields flagged as a forward-looking note in my #1877 review.

Dependency supply-chain review — clean

  • Both new runtime deps are pinned to exact versions per the repo's dependency policy (no caret ranges), and are pure-JS (no native binaries) — no ARM64/build-tool concerns.
  • Extracted all 27 packages newly added to package-lock.json by this PR's dependency tree (pdf-lib, pdfmake, fontkit, pdfkit, @pdf-lib/standard-fonts, @pdf-lib/upng, unicode-trie, unicode-properties, restructure, brotli, pako, linebreak, xmldoc, clone, dfa, js-md5, png-js, tiny-inflate, browserify-zlib, @noble/ciphers, @swc/helpers, @types/pdfkit, nested tslib/base64-js/pako/@noble/hashes copies) and queried the npm registry's bulk security-advisories endpoint directly against name+version — zero advisories for any of them.
  • npm audit --omit=dev on the full lockfile shows 3 pre-existing high-severity findings (@fastify/static, react-router / react-router-dom) — confirmed these predate this PR (no version bump for either in the diff; react-router-dom usage in new files is import-only, no upgrade) and are already tracked in memory as open recommendations. This PR does not introduce or worsen them.
  • No typosquatting indicators; both packages are the genuine, actively maintained pdf-lib and pdfmake projects.

Client-side PDF pipeline — no injection surface found

  • pdfmake is a text/layout-based PDF renderer (not an HTML renderer) — all user-controlled content (household.householdName, household.householdAddress, report.source.contactAddress, report.source.reference, invoice.vendorName, invoice.invoiceNumber) flows exclusively into pdfmake { text: ... } content nodes in coverLetterPdf.ts and overviewPdf.ts. There is no HTML/markdown mode in use, no svg content nodes built from user data, and no pdfmake link/linkToPage fields constructed from any user-controlled string — so no injection or open-redirect vector inside the generated PDF.
  • No dangerouslySetInnerHTML, innerHTML, eval(, or new Function( anywhere in the diff (grepped the full patch).
  • No href/window.open/location assignments built from report/source data in the new React components (ReportInvoiceList.tsx, ReportPdfPreview.tsx, wizard step components) — the iframe src in ReportPdfPreview.tsx is always a blob: URL generated locally from the app's own PDF output, never a raw external/user-supplied URL.

Blob/object-URL lifecycle — correctly managed

  • sinks.ts downloadPdf() creates and revokes its own object URL synchronously around the anchor-click download — no leak.
  • ReportWizardPage.tsx: the debounced-regenerate path (regeneratePdf, line ~7241) revokes the previous previewUrl before creating a new one; a dedicated cleanup useEffect (line ~7403) revokes on unmount. No missing revocation path was found across the initial-generation, regenerate, and unmount code paths.

Paperless upload path — no credential exposure

  • uploadPaperlessDocument() in paperlessApi.ts posts to ${getBaseUrl()}/paperless/documents (the app's own authenticated backend proxy) with credentials: 'include' — the client never receives or holds a Paperless API token/URL. This reuses the server-side proxy route reviewed and approved in PR feat(reports): add source report data, mark-claimed, and Paperless upload endpoints #1885 (server unchanged in this PR except test-only additions in paperless.test.ts and sourceReportService.test.ts), where I previously confirmed the outbound Paperless base URL always comes from server config, never client input (no SSRF).

Logging — no sensitive data exposure

  • The three console.error(err) calls in ReportWizardPage.tsx log only caught Error objects from failed fetch/generation calls, not report contents, tokens, or PII.

Forward-looking note from #1877 — resolved, no new finding

  • My earlier review flagged that householdName/householdAddress had no PDF consumer yet and would need explicit escaping once one shipped. Since pdfmake text nodes are inherently safe against markup injection (it's not an HTML-to-PDF renderer), that concern does not materialize here — no remediation needed.

No critical, high, medium, or low findings. No informational findings beyond what's noted above.

Verdict: APPROVED

@steilerDev steilerDev left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[security-engineer] Approved — see detailed findings in PR comment above. No critical/high/medium/low findings; dependency bulk-advisory check clean on all 27 newly added transitive packages. Verdict: APPROVED.

@steilerDev

steilerDev commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

[ux-designer] Design review of PR #1887 against the visual spec posted on issue #1879 (comment #5122593052 — note: that comment itself rendered as the literal string @/tmp/spec/1879.md due to a body-file substitution bug wherever it was posted, not actual markdown; I'm reviewing against the spec content preserved in my own working notes, and will repost the correct spec body to #1879 separately to repair that artifact).

Reviewed: WizardStepper, Step1UseCase, Step2Source, ReportInvoiceList, Step4Options, ReportPdfPreview, ReportWizardPage, and the BudgetSourcesPage overflow-menu addition. Ran stylelint on all new CSS Modules — clean, zero hardcoded-value violations.

Critical finding (blocking)

Refund amount text is unreadable in both light and dark mode. client/src/components/reports/ReportInvoiceList.module.css:86-88:

.amountNegative {
  color: var(--color-danger-text);
}

--color-danger-text is the "text-on-danger-background" token (white in light mode, near-black in dark mode) — it's only ever paired with background-color: var(--color-danger), e.g. shared.module.css .btnConfirmDelete and BudgetLineCard.module.css .deleteConfirmButton. Applied here to a plain <div> on the row's normal background (--color-bg-primary, or --color-bg-secondary on hover), it renders refund amounts as white text on a light background in light mode and near-black text on a dark background in dark mode — invisible/near-invisible in both themes, a WCAG AA contrast failure.

The spec explicitly said to reuse the #1876 refund-row recipe verbatim, and the actual #1876 implementation (InvoiceDepositsSection.module.css:353-354) uses the correct token:

.amountNegative {
  color: var(--color-danger-text-on-light);
}

One-line fix: swap --color-danger-text--color-danger-text-on-light on line 87.

Medium findings (non-blocking; fix before merge or in refinement)

  1. Empty/invisible unallocated-row icon. ReportInvoiceList.tsx:226:

    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" />

    No <path>/content — this is the Tooltip-wrapped info icon called for in the spec for each unallocated invoice row, but it currently renders nothing (an empty 16×16 box). Needs an actual glyph (e.g. an info-circle path, following the same single-path SVG authoring convention as the paperclip icon two blocks above it, or BudgetSourcesPage's docsIcon).

  2. "Has attachment" paperclip indicator doesn't follow the established icon-labeling convention. ReportInvoiceList.tsx:168-179 puts aria-label on the wrapping plain <div> (no ARIA role) and leaves the <svg> un-hidden:

    <div className={styles.paperclip} aria-label={t('sourceReports.hasAttachment')}>
      <svg ... /* no aria-hidden */>

    aria-label on a role-less <div> (implicit role generic) isn't reliably exposed by all screen readers — generic doesn't support name computation per the AT support matrix. The spec called for the BudgetSourcesPage docsIcon convention instead: aria-hidden="true" on the <svg> + a sibling .srOnly text span carrying the label (see BudgetSourcesPage.tsx:1281-1292 for the exact reference pattern already in this codebase). Also note: .paperclip sets cursor: help but there's no title attribute, so mouse users hovering get no native tooltip either.

  3. Invoice rows lack a touch-friendly click target. ReportInvoiceList.tsx:112-122 — the row is a bare <div className={styles.invoiceRow}> wrapping a raw <input type="checkbox" className={styles.checkbox} .../> (.checkbox = 1.125rem / 18px) with no <label> or htmlFor association. The only tappable control is the 18×18px checkbox itself — well under the spec's 44×44px minimum mobile touch target. Both sibling steps in the same wizard get this right: Step1UseCase.tsx wraps each card in a <label>, and Step2Source.tsx wraps each source row in a <label>. Recommend the same here — wrap .invoiceRow in a <label> (or add htmlFor/id) so the full row is tappable, not just the checkbox glyph.

Low / informational

  • Step1UseCase.tsx:15-22 double-labels the radio group: a <fieldset>+<legend> wraps a nested <div role="radiogroup" aria-label={...}> carrying the same text — screen readers may announce the group name twice on entry. Not a WCAG failure, just verbose. Step2Source.tsx's simpler role="radiogroup" aria-label div with no fieldset wrapper (one step later in the same wizard) is the cleaner precedent — consider aligning.

Deviations from the literal spec text — validated on their merits

  1. CSS-only dual-tree stepper responsiveness (WizardStepper.tsx always renders both .stepperMobile and .stepper, toggled by the 767px media query) instead of a JS width check. Validated as sound: display: none removes the hidden tree from both rendering and the accessibility/tab-order tree, so there's no duplicate-announcement or duplicate-tab-stop risk at either breakpoint — functionally equivalent to a conditional render, just with a small extra-DOM-nodes cost. No objection.

  2. Focus management lives in ReportWizardPage (a stepHeadingsRef array of 4 <h2 tabIndex={-1}> refs + requestAnimationFrame on currentStep change) rather than inside WizardStepper itself, and WizardStepper gained ariaLabel/mobileStepLabel props to stay namespace-agnostic. Validated as sound and arguably better: matches the spec's original focus-management intent exactly (requestAnimationFrame, not setTimeout; focus-as-announcement, no extra live region), keeps the shared component decoupled from page-specific heading DOM, and the added props are a genuine reusability improvement, not scope creep. No objection.

  3. Page-local status-badge variant map in ReportInvoiceList.tsx (invoiceStatusVariants useMemo, pending/paid/claimed/quotation) instead of shared Badge.module.css classes. Validated as correct: this is a verbatim match of the existing InvoicesPage.tsx invoiceStatusVariants pattern (same variable name, same status list, same useMemo shape) — it's not a deviation from the established convention, it is the established convention. No objection.

What matched the spec cleanly

  • WizardStepper: 44×44px touch targets on desktop step buttons, aria-current="step", backward-always-clickable / forward-past-maxReachedStep rendered as non-interactive <div> rather than merely disabled, all colors on Layer-2 semantic tokens, prefers-reduced-motion handled.
  • Step 1 radio cards: visually-hidden-not-display:none native radio, :has(input:checked) / :has(input:focus-visible) styling, focus ring via box-shadow: var(--shadow-focus), correctly scoped as a page-local pattern (no second caller yet).
  • Step 3 refund badge (label + formatCurrency(-amount) literal minus sign, independent of the broken color above), split badge reusing Badge.module.css .info with zero new CSS, unallocated section reusing InvoiceGroup's exact .toggleBtn/.chevron/opacity: 0.85/Badge variant="warning" recipe.
  • Step 4 checkboxes (no toggle-switch invented), cover-letter checkbox disabled + title (not hidden) when the source lacks contact info.
  • ReportPdfPreview modeled 1:1 on AutoItemizePdfPreview (same wrapper/overlay/fallback CSS recipe, same 860px breakpoint reuse, same stroke="var(--color-text-muted)" SVG convention, correct URL.revokeObjectURL on each regeneration).
  • BudgetSourcesPage "Generate report" action added as a new OverflowMenu (not a 5th inline button), existing 4 row buttons untouched.
  • i18n: spot-checked all new sourceReports.* / sources.generateReport* keys — present in both en and de budget.json.

Verdict: CHANGES_REQUIRED

Blocking on the single Critical finding (refund amount contrast/visibility failure — a one-token fix). The three Medium findings should be fixed in the same pass or in refinement; they don't need to block on their own per the severity matrix, but since a fix is already required for the Critical item, bundling all four into one follow-up commit is the efficient path.

@steilerDev

Copy link
Copy Markdown
Owner Author

[product-owner] Product Owner review of PR #1887 against Story #1879 (final story of the Bank Report Wizard mini-epic).

Scope reviewed: full diff (9,573 additions), story ACs group by group, unit/integration/E2E test content, CI run 30503657153, and the German copy for the two terminology decisions escalated to me.

Credit where due: the wizard's structure, the shared-component reuse (TriStateCheckbox, SelectionActionBar, Badge, Tooltip, EmptyState, Modal, Skeleton, FormError), the BUDGET_TABS hoist, the preview-blob-is-the-download-blob design, the claim confirmation with pending count, the skippable marking, the 409 refetch, and the Paperless-visibility gating are all built as specified. The problems below are concentrated in the PDF pipeline and in a handful of contract/i18n mismatches that the test doubles are hiding.


Blockers — functional acceptance criteria not met

B1. The PDF pipeline does not work at runtime. No report is ever produced.

E2E Gates is red. Every desktop reportWizard.spec.ts test that reaches step 4 fails (shard 2: claim walk, budget-overview smoke, empty state, both Paperless scenarios, refund integration) plus the mobile stepper test (shard 13). Step 4 renders the PDF generation failed banner and the preview iframe never mounts.

Root cause, from the failing trace's browser console:

Error: File 'Roboto-Medium.ttf' not found in virtual file system
    at Object.readFileSync (...)
    at Vt.open (...)  at c.font (...)  at c.provideFont (...)

loader.ts sets pdfMake.vfs = vfsModule.default, but that value does not expose the Roboto-Medium face. Every bold: true run in the document (tableHeader, title, the subtotal and total rows) needs it, so createPdf().getBlob() rejects unconditionally — there is no data-dependent path where this succeeds.

loader.test.ts mocks both pdfmake/build/pdfmake and pdfmake/build/vfs_fonts, so the unit suite cannot see this. Please add coverage that exercises a real bold run through the real font registration, otherwise this class of failure stays invisible.

This fails the PDF-pipeline AC group and the "preview regenerates / whatever is previewed is what ships" AC.

B2. The ?sourceId= entry point dead-ends at step 3.

sourceId is seeded from the query param, but getSourceReport is only ever called from handleSourceChange, which is only wired to the radio's onChange. Clicking an already-checked radio fires no change event, so on the deep-link path report stays null and reportStatus stays 'loading' — step 3 shows the Skeleton indefinitely, and includeCoverLetter never receives its source-derived default. The user has to select a different source and come back to escape.

E2E Scenario 7 asserts only expect(wizard.sourceRow(sourceId)).toBeChecked() and never advances to step 3, so it passes over the defect. Please extend it to walk through to a rendered preview.

Fails: "when I use a source's Generate report action, then the wizard opens at /budget/reports?sourceId=<id> prefilled to that source."

B3. The PDF grand total ignores step-3 exclusions.

In overviewPdf.ts the rows and the per-status subtotals are filtered by includedInvoiceIds, but the total row is formatCurrencyForPdf(report.totalAmount) — the server's total over all invoices. Exclude one invoice and the bank receives a report whose stated total exceeds its own line items and does not equal the sum of its own subtotals. Excluding invoices is a first-class step-3 capability, so this is reachable in the primary flow.

overviewPdf.test.ts currently enshrines the behaviour ("the final total row always reflects report.totalAmount, not a re-derived sum", with a deliberately mismatched fixture), so that test must change alongside the code. Note this is distinct from the accepted spec correction about step-2 amounts coming from the report endpoint — that one is about step 2 and step 3 agreeing, and I'm not reopening it.

Fails: "per-status subtotals + a grand total netting refunds."

B4. Refund amounts are double-negated, and the step-3 running total adds refunds instead of subtracting them.

SourceReportInvoice.allocatedAmount is negative for refund-adjustment lines — documented on the shared type ("Negative for refund-adjustment lines") and implemented that way in sourceReportService.ts (lineKind = roundedAmount > 0 ? 'invoice' : 'refund-adjustment'). The client treats it as positive:

  • ReportInvoiceList.tsx: -{formatCurrency(invoice.allocatedAmount)} renders -−€200.00.
  • ReportInvoiceList.tsx runningTotal: sum - inv.allocatedAmount evaluates to sum + 200, so the running total is inflated by twice each refund and contradicts the PDF total on the very next step.
  • overviewPdf.ts: same double negation on both the invoice-amount and allocated-amount columns.

The per-status subtotals and the server-side grand total are correct — the bug is confined to the sign flips and the running total.

overviewPdf.test.ts passes allocatedAmount: 200 (positive) for a refund fixture, which contradicts the shared contract and is why the suite is green. Please correct the fixtures to the real sign rather than the rendering to the fixtures. Fixture-vs-contract mismatch is what let this through, and it is worth a note in QA memory.

Fails: "a refund-adjustment line renders as a negative amount" and the netting AC.

B5. Status labels render raw i18n keys, including on the bank-facing PDF.

t('invoiceStatus.<status>') is called in both ReportInvoiceList.tsx and overviewPdf.ts (status column and subtotal row labels), but client/src/i18n/{en,de}/budget.json has no top-level invoiceStatus — it lives at sources.lines.invoiceStatus. With defaultNS: 'common' and no fallback that resolves it, the Status column of a report sent to a bank prints invoiceStatus.paid.

Same class, one more instance: t('sourceReports.invoiceNumber') in the step-3 row prints sourceReports.invoiceNumber: 12345 — the key is sourceReports.table.invoiceNumber.

The unit tests use a key-echoing t mock, so they assert the broken strings as though they were correct. Missing-key resolution needs coverage that uses the real bundles.

B6. Split rows are not footnoted, and split invoices lose their attachment indicator.

Four separate gaps against the step-3 and overview ACs:

  1. No split footnote exists. The only marker on a split row is *<appendixNum>, borrowed from the attachment appendix numbering. So it vanishes entirely when the invoice has no attached document, and when "Attach invoice documents" is off (appendixByInvoiceId empty). There is no footnote text explaining what the marker means — no i18n key for one exists.
  2. The marker collides with a second footnote namespace. Skipped-document footnotes are emitted as *1:, *2: restarting from 1, so *3 on a split row and *3: in the notes block refer to unrelated things.
  3. Split badge replaces the attachment indicator. isSplit ? splitBadge : hasDocuments ? paperclip : noDocument — the AC asks for the split badge and the attachment/"no document" indicator. A split invoice currently shows no attachment state at all.
  4. The list ignores the server's isSplit and recomputes allocatedAmount < invoiceAmount. That is a different question — an invoice partially allocated to one source with the remainder unallocated is not split across sources — so it produces false positives, and it can disagree with the PDF, which does use invoice.isSplit. Also, the "stage-matched" part of the attachment AC is unimplemented: the code only checks documents.length > 0 and never consults Source contact fields, household sender setting & document attachment typing #1877's attachmentType.

MUST FIX before merge — display and formatting

These do not block the review loop, but they must be corrected before this ships, and several land directly on bank-facing output.

  • M1. PDFs ignore the viewer's locale and the CURRENCY setting. formatCurrencyForPdf / formatDateForPdf call the standalone formatters with no arguments, so they fall back to en-US and EUR. A German user's bank report prints €1,234.56 and Feb 27, 2026 while the UI beside it shows 1.234,56 € and 27. Feb. 2026; a CURRENCY=CHF instance prints euros. Thread locale and currency from useFormatters() into the pipeline.
  • M2. The cover letter prints an unformatted number. t(bodyKey, { total: report.totalAmount }) yields "…totaling 1234.5." on a letter to a bank.
  • M3. The split badge double-prints the currency symbol. "partial €{{allocated}} of €{{total}}" (de: "anteilig {{allocated}} € von {{total}} €") is called with values already run through formatCurrency, giving "partial €€1,234.56 of €€10,000.00". Remove the hardcoded symbol from both locales — per CLAUDE.md the symbol comes from the formatter, never the string.
  • M4. selectedCount reuses {{total}} for two different values. "{{count}} of {{total}} | Total: {{total}}" is called with { count, total: formatCurrency(runningTotal) }, rendering "3 of €1,234.56 | Total: €1,234.56". The "of N invoices" half needs its own placeholder.
  • M5. Unallocated rows always render formatCurrency(0). The response carries invoiceAmount for these rows; €0.00 against every unallocated invoice is misleading.
  • M6. German casing: "generateReport": "Bericht Erstellen""Bericht erstellen". German does not title-case the verb.
  • M7. <SubNav ariaLabel="Budget section navigation"> in ReportWizardPage.tsx is a hardcoded English string; the i18n AC covers screen-reader text too.

Observations — not blocking, no action required in this PR

  • useState<Array<any>>([]) for skippedDocuments, where SkippedDocument[] is already exported.
  • Step 2 issues one full getSourceReport per budget source (each performing Paperless metadata lookups) purely to render amounts. Accepted as the consequence of the step-2/step-3 agreement correction — flagging only as a scaling watch item.
  • Step 4 generates the PDF twice on entry (the init effect plus the debounce effect firing on mount).
  • Review gate: only the security-engineer review is posted. Architecture, QA, and UX-designer sign-off are still outstanding — the latter is required by CLAUDE.md for the three new shared components (WizardStepper, ReportPdfPreview, ReportInvoiceList).
  • No scope creep found. Every file in the diff traces to an AC; the handleToggleDocs ternary-to-if cleanup in BudgetSourcesPage.tsx is incidental but harmless.

German terminology decisions

Both were escalated to me as bank-facing copy. Both are approved as the translator proposed them.

1. Proof of Funds → "Verwendungsnachweis" — approved, and this is the right call over the literal.

The report's payload is claimed and paid invoices, i.e. evidence of how disbursed funds were used. That is precisely what a German lender or a KfW programme means by a Verwendungsnachweis, so the term matches the artifact the user is actually handing over. The literal alternative, "Nachweis vorhandener Mittel", describes proving that funds exist — a balance confirmation — which is a different document with a different purpose; using it here would misrepresent the report's contents to the bank. Semantic accuracy beats literal fidelity on outbound financial copy.

I am deliberately leaving the English label as "Proof of Funds": it is what the story specifies and what the ACs are written against. The English/German asymmetry is intentional, not drift. If the English name later proves misleading for the same reason, that is a separate copy story, not a change to this PR.

2. Claim → "Einreichung" — approved, reads correctly.

It extends the claimed = "Eingereicht" vocabulary locked in by #1876/#1877, so a user who sees invoices marked "Eingereicht" reaches for the "Einreichung" report. That internal consistency is worth more than a more specialised term. Checking the derived strings: the cover-letter subject "Einreichungsunterlagen" and body "Hiermit reichen wir Rechnungen in Höhe von insgesamt … zur Erstattung ein." both read naturally to a German bank, and "Einreichungsbericht" as the table title is serviceable.

One optional follow-up, explicitly not requested here: for bank_loan sources specifically, German construction lenders more often call this a Mittelabruf. A source-type-aware subject line would read more idiomatically for that case. I am not asking for it — "Einreichung" has to cover subsidy programmes as well as loans, and consistency with the status label wins for now.

No glossary additions are needed; both terms reuse vocabulary already approved for the invoice status lifecycle.


AC coverage summary

AC group Result
Route, tabs & entry points Partial — route + lazy load + BUDGET_TABS hoist + Reports tab all correct; ?sourceId= deep link dead-ends (B2)
Wizard steps 1–2 Met — three radio cards with helper text; single-select sources with colour badges, use-case-appropriate amount labels, discretionary last and de-emphasised
Wizard step 3 PartialTriStateCheckbox select-all, per-row checkboxes, SelectionActionBar, status badges, collapsible non-selectable unallocated warning group and EmptyState all present; refund sign and running total wrong (B4); split footnote / attachment indicator / stage matching incomplete (B6); raw i18n keys (B5)
Preview & actions Not met — preview never renders (B1). Claim confirmation with pending count, skippable marking, 409 refetch, filename pattern, Paperless gating and success toast, and the preview-blob-is-the-download-blob guarantee are all implemented correctly and will pass once B1 is fixed
PDF pipeline Not met — pipeline throws (B1); grand total ignores exclusions (B3); refund double negation (B4); split footnotes missing (B6). Lazy dynamic import() with type-only static imports is correct, deps pinned exactly (pdfmake@0.3.11, pdf-lib@1.17.1, @types/pdfmake@0.3.3), npm audit clean per the security review. Cover letter correctly sources sender/signature from household settings and recipient/reference from the source, and gracefully omits missing fields
i18n & PDFs Partial — full sourceReports.* tree in both locales with German reviewed and approved below; PDFs correctly use hardcoded light-theme hexes while the UI uses tokens; but unresolvable keys (B5), locale/currency bypass (M1–M3), and one hardcoded aria-label (M7)

Priority order for the fix loop: B1 first (nothing else is verifiable until a PDF renders), then B4 and B5 (both are contract/fixture corrections in the same files), then B3, B2, B6, then the M-items. Re-run the full reportWizard.spec.ts suite after B1 — the remaining scenarios have never actually executed past step 3 on desktop, so expect further findings once they do.

VERDICT: CHANGES_REQUIRED

@steilerDev

Copy link
Copy Markdown
Owner Author

[product-architect] Architecture review of #1879 (bank report wizard frontend).

I verified the dependency policy, the reportPdf/ module decomposition, the lazy-loading strategy, shared-component quality, and the #1878 review closures. I also ran three empirical probes against the real pdfmake/pdf-lib packages, because the unit suite mocks the pipeline at every layer. Two of those probes found blocking defects that no test in this PR can see.

What I verified as correct

Dependency policy — fully compliant. pdfmake@0.3.11, pdf-lib@1.17.1, @types/pdfmake@0.3.3 all exact-pinned. Full lockfile regeneration: all 27 new packages (fontkit, pdfkit, restructure, pako, brotli, linebreak, @noble/*, …) hoist to root node_modules/ with zero workspace nesting — the --package-lock-only hazard was avoided. No hasInstallScript, no native binaries, no ARM64 emulation risk.

Lazy-loading is correctly airtight. Every static reference to either package is import type (erased at compile time); the only value-level references are the three import() calls in loader.ts. With splitChunks: { chunks: 'all' } and nothing in the initial graph referencing them, the ~2MB (dominated by the base64 Roboto vfs) stays in async chunks. ReportWizardPage is itself lazy()-loaded in App.tsx. Promise-caching in loadPdfLibs() is correct.

loader.test.ts is the best test in this PR. Deliberately un-mocked against the real packages, and it demonstrably earned its keep across rounds 5-6: the ESM-namespace-non-extensibility bug, the vfs_fonts default-export shape, and the Helvetica-vs-Roboto font error are all real defects that only a real-package probe could catch. This pattern should be the template for the gap in M4 below.

#1878 closures landed. Both are present and precise — sourceReportService.test.ts M2 asserts exactly one batched /api/documents/?id__in= call for a 3-invoice report and validates the id set; paperless.test.ts M3 covers malformed taskId (number/object/null) → 502 PAPERLESS_ERROR. The M2 comment explaining why URL-pattern counting substitutes for jest.spyOn under NodeNext ESM is exactly the right kind of documentation. Thank you.

No API contract drift. GET /api/source-reports, POST /api/source-reports/mark-claimed, POST /api/paperless/documents, GET /api/paperless/documents/:id/preview all already specified. No wiki contract/schema update owed.

Also clean: BUDGET_TABS hoist has zero leftover inline tab arrays across all five pages; WizardStepper responsive switch is pure CSS (@media (max-width: 767px), no JS viewport branching); zero hardcoded colors in the three new CSS modules (full token compliance); en/de key parity is exact across budget/common/errors; 11 E2E scenarios including @responsive and @smoke tags.


Blocking findings

B1 (Critical) — Unbounded PDF regeneration loop in production

ReportWizardPage.tsx:212-221regeneratePdf's useCallback dependency array includes previewUrl, and regeneratePdf itself calls setPreviewUrl(createPreviewUrl(result.blob)). The debounce effect at line 275 depends on regeneratePdf. That closes a self-retriggering cycle:

regeneratePdf() → setPreviewUrl(<new unique blob URL>) → regeneratePdf identity changes
  → effect at :262 re-fires → 400ms timer → regeneratePdf() → …

URL.createObjectURL returns a unique string per call, so the state always changes and the cycle never settles. There is no in-flight guard (isRegenerating is not checked).

I measured it. Taking ReportWizardPage.test.tsx unchanged and only replacing the createPreviewUrl mock's constant return with unique values (matching real browser behaviour), then sitting idle on step 4 with no user interaction:

PROBE generateReportPdf calls after 2.5s idle: 6

Six full report generations in 2.5 seconds, growing without bound for as long as the user stays on step 4. In production each iteration re-fetches every linked Paperless document over HTTP and re-renders + re-merges the whole PDF, and the iframe src is swapped every ~400ms so the preview can never be read.

Why the suite is green: ReportWizardPage.test.tsx:60 uses .mockReturnValue('blob:preview-url') — a constant. The second setPreviewUrl is Object.is-equal, React bails out of the re-render, and the cycle terminates at exactly 2 calls. The mock is the only thing stopping the loop.

Note the comment at :175-176 ("Don't check shouldRegenerate here — that guard is only for debounced option changes") refers to a shouldRegenerate variable that no longer exists anywhere in the file. The guard that broke this cycle appears to have been removed during the review rounds.

Fix direction: remove previewUrl from regeneratePdf's deps (revoke the previous URL via a ref or inside the setPreviewUrl updater instead of reading it from the closure), and collapse the duplicated "Initial PDF generation" effect (:224-259) and the debounce effect into one path — today both fire on arrival at step 3, so even without the loop the first report is generated twice. Please also make mockCreatePreviewUrl return unique values so the regression is actually covered, and add an idle-stability assertion (await a settle window, then assert the call count has not grown).

B2 (High) — German overview table overflows A4; rightmost column is clipped

overviewPdf.ts:212-215 uses widths: ['auto', …] for all 6-7 columns. auto sizes each column to its content and applies no total-width constraint, so the table simply runs off the page.

Measured with the real translated labels, real pdfmake layout, and the 7-column (attachDocuments, the default) shape on A4 portrait with 40pt margins — available content width is 515.28pt:

PROBE[en] tableMinWidth=504.4  maxCellLeft=504.9   (11pt headroom, 2%)
PROBE[de] tableMinWidth=553.7  maxCellLeft=550.9   OVERFLOWS by 38pt

German header labels are the driver — Zugeordneter Betrag, Rechnungsbetrag, Auftragnehmer vs Allocated Amount, Invoice Amount, Vendor. The last column (Anhang) starts at x=550.9 with the content area ending at x=555.28, leaving ~4pt for the entire column. German is the primary locale for this app's market, and this is the customer-facing artifact.

English's 2% headroom is not a safe margin either — it is one label revision away from the same failure.

Fix direction: give the vendor column '*' so pdfmake distributes remaining width and wraps rather than overflowing (widths: ['*', 'auto', 'auto', 'auto', 'auto', 'auto', 'auto']), or switch the overview page to pageOrientation: 'landscape'. Then add a regression assertion on the real layout: table._minWidth <= 515.28 for both locales. That assertion is cheap and would have caught this.

B3 (High) — PDF amounts and dates ignore the user's locale and CURRENCY config

Two related defects:

  1. coverLetterPdf.ts:64t(bodyKey, { total: report.totalAmount }) hands the raw number to i18next. i18n/index.ts:110 configures only escapeValue: false, no format function, so this interpolates String(12345.67). Confirmed in the probe output: the rendered cover-letter body is …totaling 1234.56 — no currency symbol, no thousands separator. This is the single most prominent amount in a bank-facing letter, and every other amount in the same PDF is formatted.

  2. shared.ts:32-43formatCurrencyForPdf/formatDateForPdf call formatCurrency(amount) / formatDate(dateStr) with no locale or currency argument, so they fall back to the module defaults locale='en-US', currency='EUR'. The entire point of useFormatters() (formatters.ts:287-302) is to inject resolvedLocale and the runtime currency from GET /api/config. So a de-locale instance with CURRENCY=CHF renders €1,234.56 and Jan 10, 2026 in the PDF while the invoice list on screen beside it shows CHF 1.234,56 and 10.01.2026.

This is a CLAUDE.md convention violation in substance ("Use formatDate, formatCurrency, formatPercent … never raw toLocaleDateString()" exists precisely so locale/currency stay centralized).

Fix direction: the builders are pure functions and cannot call the hook, so thread the resolved formatters in as a parameter alongside the existing t — e.g. extend the builder signatures with a { formatCurrency, formatDate } pair supplied by ReportWizardPage from useFormatters(), and pass total: formatCurrency(report.totalAmount) into the i18next interpolation.

B4 (High) — *N marker namespace collision and orphaned skip footnotes

overviewPdf.ts uses the *N marker for two unrelated numbering schemes in the same document:

  • :119*${appendixNum} on a split invoice's allocated-amount cell, numbered from appendixByInvoiceId.
  • :225-233*${footnoteNum} on the skipped-document notes, numbered from an independent counter over skippedDocuments map order.

So *2 in the table means "see Appendix 2" while *2 in the footnote list means "the 2nd document that failed to attach". Worse, the footnotes are fully orphaned: no table row ever carries a footnote reference, and the invoice key is explicitly discarded (for (const [_invoiceId, reasons] of skippedDocuments)). A reader gets *1: Document could not be retrieved with no way to tell which invoice it belongs to. An invoice with three failed documents produces three identically-worded, unattributable footnotes.

The on-screen list has the same problem — ReportWizardPage.tsx:594-602 renders only t(...reason) per skipped doc, with no vendor or invoice number.

Fix direction: separate the two namespaces (e.g. keep *N for appendices and use a distinct marker or an explicit per-row note for skips), reference the footnote from the owning invoice's row, and include the vendor/invoice number in both the PDF footnote and the on-screen list.

B5 (High) — "whatever-is-previewed-is-what-ships" breaks on generation failure

ReportWizardPage.tsx:205-208 — on a generateReportPdf rejection the handler sets previewUrl = null and previewError = true, but leaves previewBlob holding the previously generated PDF. Step4Options receives isSaving={isRegenerating} but never hasError, so disabled={isSaving} (Step4Options.tsx:113, :145) resolves to false once regeneration finishes.

Result: after a failed regeneration the user sees "Preview generation failed" with a Retry button, and the Download and "Upload to Paperless" buttons are still enabled and will emit the stale PDF built with the previous option set. Because the primary trigger for regeneration is an option toggle, the failure mode is precisely "user unchecks Attach documents, generation fails, user downloads a PDF that still has the documents attached" — the exact invariant this story is meant to guarantee.

Fix direction: clear previewBlob alongside previewUrl in the catch, and pass hasError into Step4Options so both sinks are disabled (disabled={isSaving || hasError || !hasBlob}). Please add a test asserting Download is unavailable after a failed regeneration — there is currently no test referencing previewError at all.

B6 (High, process) — No ADR for client-side PDF generation

This PR makes two significant, hard-to-reverse architectural decisions with no ADR:

  1. Generate the bank-facing PDF in the browser rather than server-side.
  2. Adopt pdfmake (document generation) plus pdf-lib (merge/append) as the toolchain.

The precedent here is unambiguous — ADR-013 (Gantt: custom SVG), ADR-017 (chart library: Recharts), ADR-033 (positioning: Floating UI) all document strictly smaller choices. grep -rli 'pdfmake\|pdf-lib\|client-side PDF' wiki/ returns nothing, and ADR-Index.md ends at ADR-033.

Please add an ADR at the next free number covering: client-side vs server-side generation (bundle weight vs server CPU/memory in a single container, and the fact that browser-side generation is what makes the preview-equals-download invariant achievable at all); why two libraries rather than one; the ~2MB lazy-chunk trade-off and how it is contained; and the pdfmake-0.3.x-specific constraints the loader tests pinned down (CJS interop default, vfs_fonts shape, Roboto-only default font registry) — that last set is real institutional knowledge and belongs in the wiki, not only in test comments. Link it from ADR-Index.md, push the wiki/ submodule, and commit the bumped submodule ref on this branch.


Medium — please address in refinement, not blocking

  • M1 ReportWizardPage.tsx:81useState<Array<any>>([]). SkippedDocument[] is exported from ./types.js and already imported into this module's dependency graph. Direct violation of the no-unjustified-any standard; note CI Static Analysis does not run eslint, so the warning was never surfaced.
  • M2 shared.ts:11-22LIGHT_SOURCE_PALETTE is dead code. It is defined, re-exported from index.ts, and unit-tested, but no production module consumes it. It also hardcodes values that duplicate the --color-source-N-dot design tokens (ux-designer's domain), and has already drifted: #10b981 is emerald-500 whereas the token is var(--color-green-500). Delete it and its tests, or wire it up deliberately.
  • M3 merge.ts:199-201catch { // Skip failed documents } swallows a copyPages failure without pushing to skippedDocuments. Since the appendix number was already assigned in step 1 and printed in the table, the report can reference an "Appendix 3" whose pages are absent, with no footnote. Record a skip in this catch.
  • M4 No test renders the real content builders through real pdfmake. merge.test.ts:63-99 mocks ./loader.js, ./shared.js, ./coverLetterPdf.js, ./overviewPdf.js and ../paperlessApi.js; ReportWizardPage.test.tsx mocks generateReportPdf wholesale; loader.test.ts renders only { text: 'hello' }. So the actual document definition is never laid out by the actual library — which is exactly why B2 and B3 shipped through 3 review rounds and 355+ green tests. One integration test that pipes buildCoverLetterContent + buildOverviewContent into the real pdfMake.createPdf(...).getBlob() for both locales, asserting the width bound from B2, closes this whole class. The bar loader.test.ts already sets is the right one.
  • M5 merge.ts:56 and :191PDFDocument.load(bytes) runs twice per attached document (validate, then copy). Bytes are cached but the parse is not; worth a single cached parse.
  • M6 merge.ts:34-78 — document fetches are strictly serial (await fetch inside nested for). Fine for a handful of invoices, but a 40-invoice claim report serializes 40+ round trips. Bounded-concurrency (4-6) would be a meaningful improvement; low priority at current scale, but it compounds badly with B1.
  • M7 sinks.ts:9-18URL.revokeObjectURL(url) fires synchronously immediately after anchor.click(). Chrome tolerates it; Firefox has historically cancelled the download. Defer the revoke (setTimeout(…, 0)).
  • M8 coverLetterPdf.ts references style: 'normal' eight times, but merge.ts:126-156 defines only title/subheader/header/tableHeader/tableCell/small. I probed this against real pdfmake: unknown style names are silently ignored, so it is not a crash — but they are dead references that read as intentional styling. Either define normal or drop the property.

VERDICT: CHANGES_REQUIRED

The module decomposition is genuinely good — loader/shared/coverLetterPdf/overviewPdf/merge/sinks is the right seam set, the builders are pure and testable, lazy-loading is airtight, dependency hygiene is exemplary, and loader.test.ts sets a standard I'd like to see applied more widely. B1 is the hard blocker: an unbounded generation loop that hammers Paperless and makes the preview unreadable, invisible to the suite only because a mock returns a constant. B2/B3 make the German-locale artifact incorrect, B4/B5 break the report's own integrity guarantees, and B6 leaves a significant architectural decision undocumented.

B1, B2, B3, B5 each come with a concrete assertion that would have caught them — please land those assertions with the fixes so the same class of defect cannot recur behind a green suite.

steilerDev and others added 4 commits July 30, 2026 04:33
… refund signs, locale formatting, footnote model

Resolves PR #1887 review findings: pdfmake vfs/font wiring via the real
addVirtualFileSystem()/addFonts() API (fixes runtime crash on any bold
run), kills the unbounded preview-regeneration loop, fixes refund
double-negation and running-total sign errors, threads locale-aware
formatters into the pure PDF content builders, and reworks the
footnote/marker model so split rows are footnoted without colliding
with the appendix numbering namespace. Also fixes dead ?sourceId=
deep-linking and gates PDF actions correctly during regeneration.

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 qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude translator (Sonnet 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com>
…enarios

Moves setMaxReachedStep(4) from the PDF-generation-ready effect to the
Step 3 Next button's onClick, so Step 4 stays forward-locked until the
user has actually reached it. Also stabilizes 4 E2E scenarios that had
stale assumptions: preview-regeneration is now proven via the iframe's
blob: src changing instead of racing the transient loading spinner, the
empty-state scenario branches on the household-wide unallocated list
(which has no per-source filter) instead of assuming EmptyState is
always reachable, the mobile-stepper assertion checks visibility instead
of DOM presence (both stepper trees are always mounted, toggled by CSS),
and the refund scenario seeds a genuinely out-of-scope invoice so its
refund produces its own negative row instead of merging into a positive
one.

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>
The claim-walk scenario's Source A lacked a contact address and
reference number, so the cover-letter checkbox was correctly disabled
by the app (Story #1877 rule) and the toggle click timed out. Seeds
both fields on the source and widens createBudgetSourceViaApi's data
type to accept them.

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com>
setShowClaimConfirm(false) was only called on the 409 conflict path.
The success path and the two generic error paths left the confirmation
modal's backdrop mounted, intercepting pointer events on the success
banner's "View invoices" link.

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>
@steilerDev

Copy link
Copy Markdown
Owner Author

[ux-designer] Round 2 re-review of PR #1887, verified against current HEAD (f6dbacfc).

Round-1 findings — verified against actual code (not just the changelog)

# Finding Status Verified at
Critical .amountNegative used --color-danger-text (text-on-danger-bg, invisible on normal row bg in both themes) Fixed ReportInvoiceList.module.css:94-96 now var(--color-danger-text-on-light) — matches the #1876 InvoiceDepositsSection.module.css recipe exactly
Medium Unallocated-row Tooltip icon was a childless <svg/>, rendered nothing Fixed ReportInvoiceList.tsx:225-234 now has a real info-circle <path> + aria-hidden="true"
Medium Paperclip "has attachment" indicator used aria-label on a role-less <div> instead of the docsIcon convention Fixed ReportInvoiceList.tsx:163-177 now aria-hidden="true" on the <svg> + sibling <span className={styles.srOnly}> — matches BudgetSourcesPage's docsIcon pattern exactly
Medium Invoice rows had no <label>/htmlFor wrapper — 18px checkbox was the only tappable target, under the 44px minimum Fixed ReportInvoiceList.tsx:106-124 now wraps checkbox + vendor info in <label className={styles.checkboxWithContent}>; ReportInvoiceList.module.css:45-51 sets min-height: 44px on .checkboxWithContent
Low/informational Step1UseCase.tsx double-labels its radiogroup (<fieldset><legend> wrapping a nested role="radiogroup" aria-label with the same text) Not changed git diff 915cc62c f6dbacfc -- .../Step1UseCase.tsx is empty — this file wasn't touched by any of the four follow-up commits. Restating for the record since it was reported fixed: it wasn't, but this was informational-only in round 1 (verbose double-announcement, not a WCAG failure) and doesn't change the verdict. Still worth a one-line cleanup (drop either the fieldset/legend or the aria-label, per Step2Source.tsx's simpler precedent) whenever this file is next touched.

Re-ran stylelint across all touched CSS Modules (WizardStepper, reports/*, ReportWizardPage) — clean. Grepped the whole reports/wizard tree for --color-danger-text and raw hex/rgba() — only the one, now-correct, -on-light reference remains.

Bonus fixes noticed while re-reading (beyond my original findings, both good)

  • Split badge + attachment indicator now coexist: ReportInvoiceList.tsx:147-180 changed from a mutually-exclusive isSplit ? … : hasDocuments ? … : … chain to independent {invoice.isSplit && <SplitBadge/>} followed by {hasDocuments ? <Paperclip/> : <NoDocument/>}. A partially-allocated invoice that also has a linked document now correctly shows both facts instead of the split badge hiding the paperclip. Good catch — these are two independent properties of an invoice and shouldn't have been exclusive.
  • Forward-lock semantics: setMaxReachedStep(4) moved from the background PDF-generation-ready effect to the Step 3 "Next" button's onClick (commit 0bf8e674) — Step 4 now only unlocks on explicit user progression rather than implicit completion of an async side effect. Matches the spec's "forward-lock represents where the user has actually navigated" intent more precisely than the original.
  • Claim modal close bug (f6dbacfc): the confirmation modal's backdrop was staying mounted on the success and generic-error paths (setShowClaimConfirm(false) was only called on the 409-conflict path), intercepting pointer events on the success banner's "View invoices" link. Now closes on all three paths. Not something I'd have caught in a static review — good that E2E surfaced it.
  • Mobile/desktop stepper dual-tree: my round-1 validation (display: none correctly removes the hidden tree from the accessibility/tab-order tree, so the CSS-only toggle is equivalent to a JS-conditional render) is now also confirmed by the E2E mobile-viewport assertion switching from DOM-presence to visibility (commit 0bf8e674) — consistent with what I signed off on, and green in CI shard 13.

Verdict: APPROVED

All blocking and non-blocking findings from round 1 are resolved and verified directly in the current file contents (not just inferred from the commit message). The one item still open (Step1UseCase.tsx redundant radiogroup labeling) was informational-only and doesn't block merge.

@steilerDev

Copy link
Copy Markdown
Owner Author

[product-owner] Round-2 review of PR #1887 against Story #1879.

I re-verified every item from my round-1 review against the diff at f6dbacfc independently rather than relying on the fix summary. All six blockers and all seven MUST-FIX items are genuinely resolved. Details below, then one carved-out AC and two housekeeping notes.

Blockers — all verified fixed

B1 — PDF pipeline (font failure). Fixed at the root, not worked around. loader.ts now calls pdfMake.addVirtualFileSystem(vfsFontMap) followed by addFonts({ Roboto: { normal, bold: 'Roboto-Medium.ttf', italics, bolditalics } }) — the missing font-family registration was the actual cause, and this is the correct pdfmake 0.3.x API for it. Confirmed in CI: E2E shard 2 (desktop) and shard 13 (mobile) are both green, and the complete claim walk — select → exclude → preview → download → mark claimed → View invoices — passes in real browsers.

The part I want to call out: realRender.test.ts is exactly the coverage I asked for, and then some. It mocks nothing in the pipeline (real pdfmake, real pdf-lib, real addVirtualFileSystem/addFonts, real en/de bundles loaded into a real i18next instance, real formatters), stubs only global.fetch and serves genuine pdf-lib-generated bytes so the embed and skip paths run on real PDFs. It asserts every declared style including all four bold variants renders without throwing in both locales, and it asserts the excluded invoice is absent from both the fetch calls and the grand total. It even pins the '*'-first column-width contract specifically to stop German label text overflowing. This closes the exact hole that let B1 ship green.

B2 — ?sourceId= deep link. New effect: if (useCase && sourceIdFromQuery && !report) handleSourceChange(sourceIdFromQuery). The !report guard correctly prevents a refetch loop, and the useCase gate means it fires at the right moment rather than before a report type exists. E2E Scenario 7 now walks through to a ready preview instead of stopping at toBeChecked().

B3 — grand total vs. exclusions. merge.ts computes includedTotal by filtering to includedInvoiceIds then reducing allocatedAmount, and threads it into both buildOverviewContent and buildCoverLetterContent. The total row is now formatCurrency(includedTotal) and the cover-letter body interpolates the same value, so the letter, the table rows, the subtotals and the grand total all describe one consistent set of invoices. Threading it into the cover letter as well was the right call and went beyond what I flagged.

B4 — refund sign contract. All client-side negation removed: runningTotal is now sum + inv.allocatedAmount, and both the list row and the PDF's allocated cell render formatCurrency(invoice.allocatedAmount) unmodified. Fixtures corrected to allocatedAmount: -200 / -300, so the tests now assert the contract instead of contradicting it. The step-3 running total and the PDF grand total agree.

One deliberate change I'm accepting: the refund row's Invoice Amount column now shows the invoice's gross positive amount in the refund colour, rather than a negated gross. That is the more defensible reading — the invoice's amount genuinely is positive; the refund is the contribution — and the row stays unambiguous via the negative allocated amount, the refund colour, and the (refund) note. Recording it as a decision, not an oversight.

B5 — unresolvable i18n keys. Re-pathed to sources.lines.invoiceStatus.<status> in ReportInvoiceList and in overviewPdf (both the status column and the subtotal labels), and to sourceReports.table.invoiceNumber in the step-3 row. I re-parsed both en/budget.json and de/budget.json to confirm each path resolves.

B6 — split footnotes and attachment indicators. Properly re-modelled rather than patched:

  • Split invoices are tracked from includedInvoiceIds.has(id) && invoice.isSplit, independent of attachments — so the marker survives with no linked document and with "Attach invoice documents" off.
  • Two distinct marker namespaces: †N for splits, *N for skipped documents. No more collision.
  • Real footnote text now exists (sourceReports.table.splitFootnote), in both locales, and each footnote names the vendor and invoice number: "Amount shown reflects only the portion allocated to this source." / "Der angezeigte Betrag umfasst nur den dieser Quelle zugeordneten Anteil."
  • The split badge is now {invoice.isSplit && …} with the paperclip/"no document" indicator rendered separately, so the two coexist as the AC requires.
  • The list uses the server's isSplit field instead of the allocatedAmount < invoiceAmount heuristic, so step 3 and the PDF can no longer disagree about which rows are split.

MUST FIX items — all verified fixed

  • M1 Locale and currency threaded end to end. formatCurrencyForPdf/formatDateForPdf are gone from shared.ts; a Formatters object from useFormatters() (locale + CURRENCY config) is passed into mergeoverviewPdfcoverLetterPdf. realRender.test.ts exercises both en-US and de-DE.
  • M2 Cover letter interpolates formatters.formatCurrency(includedTotal), not a raw number.
  • M3 splitBadge is now 'partial {{allocated}} of {{total}}' / 'anteilig {{allocated}} von {{total}}' — hardcoded symbol removed from both locales, so the formatter is the single source of the currency symbol.
  • M4 selectedCount is now '{{count}} of {{totalCount}} | Total: {{totalAmount}}' with three distinct placeholders, and the call site passes totalCount: allocatedInvoices.length and totalAmount: formatCurrency(runningTotal).
  • M5 Unallocated rows render formatCurrency(invoice.invoiceAmount) instead of a hardcoded zero.
  • M6 "Bericht erstellen" — casing corrected.
  • M7 <SubNav ariaLabel={t('sourceReports.subNavAriaLabel')}>, with the key present in both locales.

Also picked up along the way and not asked for: an srOnly label on the paperclip so the attachment state is conveyed non-visually, and the claim-modal backdrop fix in f6dbacfc (the confirmation modal only unmounted on the 409 path, leaving a backdrop that swallowed clicks on the success banner's "View invoices" link — a real defect my round-1 review missed because the preview never rendered far enough to reach it).

One AC consciously deferred → #1888

The step-3 AC also asked for a stage-matched attachment indicator. That part is not implemented — the indicator still lights on documents.length > 0 without consulting attachmentType. I am accepting this as a tracked deferral rather than opening a third round, and I want to be explicit that this is a partial AC rather than claim it's met.

Reasoning: attachmentType is invoice-only and frequently null (per #1877, the invoice-creation picker hard-sets 'invoice' and only the detail-page picker offers a choice). A naive "stage must match" rule would hide the paperclip for legitimately attached documents whose type is merely unset — worse than current behaviour. The null rule needs deciding first, which makes this a design question, not a one-line fix, and it does not affect the PDF, the claim flow, or any amount. Filed as #1888 with the null-handling question as its first AC, linked blocked-by #1879, on the board in Backlog.

Housekeeping — please fix before merge, non-blocking

  1. Stale header comment in e2e/tests/budget/reportWizard.spec.ts. The file still opens with a "NOTE ON CURRENT IMPLEMENTATION STATE" block describing a budgetSources.map is not a function blocker and asserting that "every scenario below that progresses past Step 1 fail[s] until fixed". That was true when written and is now false — the shards are green. Left in place it will mislead the next reader into thinking the suite is expected to fail. Please delete it.
  2. E2E shard 5 is red, and it is not yours. The two failures are invoices.spec.ts "Effective Amount"/"Remaining Amount" (Error: Column "Remaining Amount" not found among visible table headers) and navigation/dashboard "card is dismissed" — the same two tests, in the same shard, that failed in round 1 before any of these fixes existed. This PR's only touch to InvoicesPage.tsx is the BUDGET_TABS hoist, which cannot remove a table column. The first is a known, already-documented flake (server-side singleton table.invoices.columns preference with a debounced save leaking across retries, from story Deposit refunds with negative claim adjustments #1876) whose fix has evidently regressed. Not attributable here and not a beta blocker, but E2E Gates is required on main, so it must be triaged before this mini-epic is promoted. Worth a separate bug rather than absorbing it into this story.

Scope and process

No scope creep. All four fix commits are tightly scoped to review findings, with correct co-author trailers. Quality Gates and Static Analysis are green. Architecture, QA, and UX-designer sign-off are still outstanding from my round-1 note — UX review in particular remains required for the three new shared components (WizardStepper, ReportPdfPreview, ReportInvoiceList), and it should not be skipped just because the functional review has cleared.

German terminology (Verwendungsnachweis, Einreichung) stands as approved in round 1; I re-checked the new strings (splitFootnote, subNavAriaLabel) and both read correctly.

AC coverage

AC group Result
Route, tabs & entry points Met — route, lazy load, BUDGET_TABS hoist, Reports tab, and the ?sourceId= deep link now walked to a ready preview in E2E
Wizard steps 1–2 Met
Wizard step 3 Met, less the stage-matched indicator deferred to #1888 — refund sign, running total, split badge + attachment coexistence, server isSplit, unallocated group, EmptyState, resolved labels all correct
Preview & actions Met — verified in real browsers end to end: preview, download, claim confirmation with pending count, skippable marking, 409 refetch, Paperless gating, and the preview-blob-is-the-download-blob guarantee
PDF pipeline Met — renders for real in both locales; totals reflect only included rows; refund signs correct; split and skip footnotes distinct and unconditional; lazy dynamic import(), exact pins, clean audit
i18n & PDFs Met — all paths resolve, locale and currency threaded into every builder, light-theme PDFs vs. tokenised UI preserved

Good, disciplined round. The fixes went after root causes rather than symptoms, and the unmocked render test is the kind of change that stops this class of defect recurring — please carry that pattern into any future third-party rendering work.

VERDICT: APPROVED

@steilerDev

Copy link
Copy Markdown
Owner Author

[product-architect] Round 2 re-review of #1879.

I re-ran every probe from round 1 against f6dbacfc, plus new ones, rather than reading the diff and taking the fixes on trust. All six blockers are genuinely fixed — and in each case I confirmed the property, not just the code change. The 228 tests across reportPdf/, ReportWizardPage/, components/reports/ and WizardStepper/ pass locally.

B1 — Regeneration loop: FIXED (verified)

previewUrl is out of regeneratePdf's dep array (now [report, useCase, excludedInvoiceIds, attachDocuments, includeCoverLetter, household, t]), with previewUrlRef carrying the revoke target, generationIdRef discarding stale results, and hasGeneratedRef splitting first-generation from debounced regeneration. Nothing the callback writes can change its own identity.

I re-ran my round-1 probe and added the case the QA test does not cover — settling after a debounce-path regeneration:

PROBE calls after option toggle + 3s idle: 2   (was: 6 in 2.5s, unbounded)

Both entry paths settle. mockCreatePreviewUrl now returns unique values by default (blob:preview-url-${++previewUrlCallCount}), so the masking mock is gone repo-wide, and the dedicated no-runaway test at ReportWizardPage.test.tsx:420 pins it with a 1300ms idle window. The comment explaining why a fresh Blob per call is required is exactly the right thing to have written down.

B2 — German A4 overflow: FIXED (verified by measurement)

'*'-first widths. Measured on the real pdfmake layout, 7-column shape, both locales:

PROBE[en-US] sumCalcWidths=399.3  maxCellLeft=504.9  maxHorizRatio=0.902  -> FITS
PROBE[de-DE] sumCalcWidths=422.9  maxCellLeft=536.1  maxHorizRatio=0.963  -> FITS
                                                     (was 0.992 with the last column clipped)

Both fit inside the 515.28pt printable width with the right margin at x=555.28. The '*' column absorbs the constraint and wraps instead of overflowing.

One correction, and it is mine to make. realRender.test.ts:346-355 asserts the declared widths array and states that computed widths are "NOT accessible via the public API". That is not correct — pdfmake mutates the content objects in place during render (_minWidth, _calcWidth, positions[].horizontalRatio), which is how both of my measurements above were taken, and which ADR-034 itself documents. But ADR-034's testing rule #1 as I wrote it is also wrong: it prescribes table._minWidth <= 515.28, and on this now-correct code German _minWidth is 538.9 — because _minWidth is the unconstrained minimum, which the '*' column is designed to exceed. Following my own ADR would have produced a test that fails on correct code. See the ADR corrections below. Not blocking: the declared-widths assertion does pin the fix, and I have verified the real property by hand.

B3 — Locale and CURRENCY: FIXED (verified end-to-end)

useFormatters() is threaded from the page through merge.ts into both builders, and the cover letter now pre-formats includedTotal before it reaches t(). Rendered German output:

Hiermit reichen wir Rechnungen in Höhe von insgesamt 3.500,00 € zur Erstattung ein.
30. Juli 2026
10.000,00 €   4.000,00 €*1†1   -500,00 €   Gesamt 3.500,00 €

Correct German grouping/decimal separators, symbol placement, long-form date, and correctly-signed refund lines. The grand total is included-only and correctly signed (4000 + (-500) = 3500).

You also fixed two currency defects I had not flagged, both real: splitBadge had a hardcoded in the en and de strings, and selectedCount used the same {{total}} placeholder for both a count and an amount (so both rendered the same value). Now {{totalCount}}/{{totalAmount}} with pre-formatted input. subNavAriaLabel is translated rather than hardcoded, and Bericht ErstellenBericht erstellen is the correct German orthography.

B4 — Marker namespaces: FIXED (verified)

Two distinct glyphs, both referenced from the owning row, both attributed:

4.000,00 €*1†1
*1: Elektro Meier (RE-100) — Dokument konnte nicht abgerufen werden
†1: Elektro Meier (RE-100) — Der angezeigte Betrag umfasst nur den dieser Quelle zugeordneten Anteil.

No collision (appendix numbers stay in their own column), no orphans, and vendor + invoice number on every footnote. †N is now unconditional for splits rather than depending on appendix presence.

B5 — Stale export on failure: FIXED

The catch nulls previewBlob and previewUrlRef alongside previewUrl, and all four Step-4 actions are gated isSaving || hasError || !hasBlob. Covered by the new test at ReportWizardPage.test.tsx:447.

B6 — ADR: FIXED

ADR-034 exists (wiki 0cd728d, 149 lines), is linked from ADR-Index.md, and the submodule pointer is bumped on the branch. It covers all five alternatives with the capability-seam rationale for two libraries, the lazy-loading invariant plus its grep guard, the six pdfmake 0.3.x constraints, and the consequences including the unbounded client-side merge memory and the Roboto-only typography lock-in.

Mediums from round 1

M1 (anySkippedDocument[]), M2 (LIGHT_SOURCE_PALETTE and the PDF-local formatters deleted), M3 (step-4 catch now records a skip), M7 (deferred revokeObjectURL), M8 (normal style defined) — all done. M4 closed by realRender.test.ts with real pdfmake/pdf-lib/i18next across en+de. M5/M6 (double parse, serial fetches) correctly left as documented trade-offs in the ADR.


Not blocking, but please pick up

1. E2E Gates is failing — the handoff said it was green. E2E Tests (Shard 5/16) failed (original and retry1); mergeStateStatus is UNSTABLE. It is not a report-wizard test:

invoices/invoices.spec.ts:841 — "Effective Amount" column (Issue #1876)
Error: Column "Remaining Amount" not found among visible table headers (3000ms predicate timeout)

All 16 reportWizard-bearing shards pass, and this PR's only touch to InvoicesPage.tsx is the BUDGET_TABS hoist (no column changes), so it looks pre-existing or flaky. E2E Gates is a required check on main only, so this does not block the beta merge — but please confirm it reproduces on beta before it reaches the promotion PR, where it will block. Worth a rerun and a note either way rather than carrying an unexplained red shard forward.

2. ADR-034 corrections (my error, two lines). I own this page; please land these with a pointer bump. I did not push them myself because the base-repo wiki/ working tree has an unrelated uncommitted Prettier reformat from another session and I did not want to sweep it into your branch.

  • Line 67, module table — shared.ts is described as holding "PDF-local formatters", but this PR deleted formatCurrencyForPdf/formatDateForPdf. Change that cell to: Page header/footer builders, table layout constants, refund text color.

  • Line 107, testing requirement EPIC-01: Authentication & User Management #1 — replace:

    1. Real-layout width bound, per locale. Assert table._minWidth <= 515.28 for both en and de.

    with:

    1. Real-layout width bound, per locale. After getBlob(), pdfmake annotates the mutated table node: assert max(positions[].horizontalRatio) <= 1 (equivalently, the sum of table.widths[]._calcWidth stays within the 515.28pt printable width) for both en and de. Do not assert on _minWidth — that is the unconstrained minimum, which a '*' column is designed to exceed while still laying out correctly (German measures 538.9pt yet fits).

    Then realRender.test.ts's "not accessible via the public API" comment can be replaced with the real assertion.

3. formatters and includedTotal are optional parameters with fail-silent fallbacks (overviewPdf.ts / coverLetterPdf.ts). Omitting formatters renders every amount as and the cover-letter total as an empty string, with nothing raising. The production path passes both and is covered, so this is latent — but the builders cannot produce a valid report without them, so they should be required. Making them required is a compile-time guarantee replacing a silent wrong-output mode, in exactly the area B3 already burned us.

4. Step-4 merge failures reach the UI but not the PDF (merge.ts:222). The new skip entry is pushed after buildOverviewContent has already run, so a copyPages failure shows in the on-screen list while the PDF still carries an appendix number pointing at absent pages and no footnote. Low probability (the document parsed successfully in step 1) and a clear improvement over silence, but the PDF-side gap remains. Also, reason: 'footnoteInvalidPdf' is slightly misleading for a merge failure. Cheapest real fix is to hoist the merge before content building, or to add a third reason code.


VERDICT: APPROVED

Every blocker is fixed at the property level, not just the code level, and the two fixes that were hardest to see are now pinned by tests that would actually fail if regressed: unique-value mocks plus an idle-stability assertion for B1, and unmocked real renders across both locales for B2/B3/M4. The extra currency defects you found in splitBadge and selectedCount were genuine and outside what I flagged — that is the real-render discipline paying for itself immediately.

Please treat the shard-5 triage as a gate for the betamain promotion rather than for this merge, and fold the two ADR-034 corrections in so the wiki does not ship a testing rule that fails on correct code.

@steilerDev
steilerDev merged commit bd3f7a6 into beta Jul 30, 2026
31 of 33 checks passed
@steilerDev
steilerDev deleted the feat/1879-report-wizard branch July 30, 2026 04:00
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.13.0-beta.29 🎉

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