feat(reports): allow blob preview under CSP, expandable invoice items and deposit source allocation - #1894
Conversation
… and deposit source allocation - Widen Fastify helmet frameSrc CSP to allow blob: so the report wizard's pdfmake-generated preview renders in its iframe instead of being blocked (#1891, #1892) - Add expandable per-invoice budget-line/deposit rows to the report wizard's step-3 invoice list (ReportInvoiceList), sourced from a richer SourceReportInvoice payload (budgetLines/deposits) - Add invoice_deposits.budget_source_id (migration 0044) so deposits can be tagged directly to a budget source, with a source-selection heuristic in InvoiceDepositsSection (#1893) Fixes #1891 Fixes #1892 Fixes #1893 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.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>
assertFrameActuallyNavigated did a single, immediate page.frames().find() right after the loading overlay hid / the src attribute changed, racing the browsing context's actual navigation (CI PR #1894, shard 2, ~2.6-3.2s failures with zero CSP-violation console messages — proving the CSP blob: fix itself was never the problem). Now polls via expect().toPass, re-reading the expected src on every retry so a mid-poll regeneration is picked up rather than asserted against a stale value; waitForPreviewReady and waitForPreviewRegenerated share the single poller. 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>
…rame navigation Headless Chromium (Playwright's bundled shell) has no PDF viewer plugin: an <iframe src="blob:..."> pointed at a PDF silently stays at about:blank with zero CSP-violation console messages, whether or not frame-src actually permits blob:. page.frames() matching therefore cannot distinguish a correctly configured CSP from a broken one in CI (CI run 30530648400, shard 2) — a structural gap, not a timing race. Replaces the frame-navigation proof with: (1) a direct CSP header assertion via page.request.get() against helmetPlugin.ts's actual frame-src directive, (2) the existing zero-console-violation check as defense in depth, and (3) an in-page fetch of the blob: src asserting a non-trivial size and the expected MIME type, proving the iframe's src points at real content without depending on a navigation the shell can't perform. 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>
The in-page fetch(blobSrc) added by the prior fix (verifying blob size and MIME type) is itself a connect-src-governed request, and the app's CSP connect-src is 'self' — it does not and must not include blob:. Every scenario failed in ~2s with "TypeError: Failed to fetch" (CI run 30531695763, shard 2). The production app never fetches its own preview blob (the browser resolves <iframe src="blob:...> internally), so loosening connect-src to accommodate this test technique would weaken production CSP for no product reason. Hardened preview verification is now two signals: the CSP frame-src header directive assertion (deterministic core) plus zero CSP-violation console messages (defense in depth), applied once the regenerated blob src has settled. Both superseded attempts (frame-navigation polling, in-page blob fetch) are documented in assertPreviewHardened's docstring and general-e2e-patterns.md so a future refactor doesn't re-try either. 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>
|
[security-engineer] Security review of PR #1894 (story #1891 follow-up: report wizard CSP fix, deposit→budget-source tagging, richer source-report payload). Verdict: APPROVED — no Critical/High findings. Two Informational notes below (non-blocking). Scope reviewed
CSP
|
|
[product-architect] Architecture Review — PR #1894 (Story #1891 / bugs #1892, #1893)VERDICT: CHANGES_REQUIRED — 1 critical financial-correctness defect in the dual-rail aggregation. Everything else in the PR is well-built; the fix is localized to ~6 lines in one function. CRITICAL — Rail A + Rail B double-count a tagged deposit when its invoice also has budget lines
The unit test at I verified it empirically. Invoice €1000 / status Blast radius — every consumer of the new pair:
This is the happy path, not an edge case. Story #1893's default heuristic pre-selects the picker to the invoice's own budget-line source. So the most ordinary flow — open an invoice that has budget lines, add a deposit, accept the pre-filled source, save — lands exactly here. Why the test suite did not catch it: the only combined-rail test, Fix direction. A tagged deposit still represents money paid against its invoice; it must stay in the residual denominator and only lose its pro-rata line apportionment. In Please also add coverage for the same-invoice mixed case at all three levels ( MediumM1 — N+1 queries reintroduced in M2 — M3 — An invoice can vanish from the report entirely. The new Low
Verified and endorsed
CI note
|
|
[ux-designer] Design review against the visual spec posted on #1891. Verdict: APPROVED (with non-blocking findings — please address before merge or in refinement) What matches the spec exactly
Findings (non-blocking — Medium)
Low / informational
None of the above are contrast failures, broken dark mode, or blocking a11y defects (keyboard traps, unreachable controls) — all are addressable in refinement per the decision matrix. Nice work getting the tri-state semantics and the source-tagged deposit contract exactly right; those were the highest-risk parts of this spec. |
|
[product-owner] PR Review — Story #1891 (32 acceptance criteria)Verdict: CHANGES_REQUIRED — 31/32 ACs met. One functional blocker in §4 (AC 4.6): the tagged-deposit aggregation double-counts, inflating bank-facing claim amounts on the default picker path. Everything else in this PR is in good shape, and §1–§3 are genuinely well done. The blocker is a single, localized formula error with a one-filter fix. AC coverage
BLOCKER — AC 4.6: a source-tagged deposit is counted twice, not "100% toward its tagged source"
The intent of this AC (and of the locked-in decision it came from) is that tagging redirects a deposit's contribution from pro-rata to 100%-to-the-tagged-source. The implementation adds Rail B on top of an unreduced Rail A. In const totalDepositTypeAmount = deposits // untagged only
.filter((d) => d.entryType !== 'refund')
.reduce((s, d) => s + d.depositAmount, 0);
const residualFraction = Math.max(0, safeInvoiceAmount - totalDepositTypeAmount) / safeInvoiceAmount;Because the tagged deposit never reaches Repro — this is the default path, not an edge caseInvoice €1000, status
AC 4.2 makes the picker default to the invoice's only source — so the ordinary act of adding a deposit and accepting the default inflates that invoice's claim by the deposit amount. On a document sent to a financing bank, that is an overstated claim. The two-source case is affected too: This is currently encoded as expected behaviour in the test suite, which is why CI is green:
Per the source-of-truth hierarchy (spec > production code > test code), the AC wins: these tests must be corrected, not the AC re-interpreted. The wiki page written to match the implementation ( FixKeep tagged deposits out of Expected numbers after the fix:
This applies identically to MUST FIX before merge (non-blocking, no re-review loop needed)
Verified good§1 CSP (5/5). On AC 1.4/1.5: I accept the reworked verification. The AC asked the assertion to prove the frame renders; the final check instead asserts the emitted CSP §2 Chip (4/4). Fixed with a scoped §3 Expansion (11/11). Real §4 (11/12). Migration 0044 is nullable with no backfill and an index (AC 4.1); the picker's none/single/largest heuristic and its three distinct hints are correctly implemented (AC 4.2–4.4, closing #1893); clearing stores CI
Re-request review once the Rail A residual fix and the corrected tests are in — I'll re-verify AC 4.6 against the three numbers above and re-check nothing in §4's untagged path moved. |
… fixes for wizard expansion - splitByDepositsExcludingTagged now keeps tagged deposits in the residual denominator, fixing Rail A/B double-counting (canonical case: 1000 EUR budget line + 400 EUR tagged deposit now yields a residual of 1000, not 1400). - isSplit now spans both budget lines and tagged deposits, not just budget lines. - Mobile responsive fix for the report invoice card list, plus 5 UX-medium and 2 UX-low findings from the review round. - Test reconciliation, new isSplit regression tests, and stale docstring cleanup. 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 qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com>
…Page tests jsdom renders both the desktop table and mobile card-list checkbox instances simultaneously (CSS hides one per viewport at runtime), so ambiguous getByRole queries matched two elements; switched to getAllByRole(...)[0]! per established convention. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
|
[product-architect] Architecture Re-Review — PR #1894 (Story #1891 / bugs #1892, #1893)VERDICT: APPROVED — the critical Rail A/Rail B double-count is fixed exactly as prescribed, verified on CRITICAL — Rail A/Rail B double-count: RESOLVEDVerified
Legacy containment re-confirmed. Test arithmetic — I re-derived every reconciled assertion independently:
The cross-source sum-to-1000 sanity assertion is the right invariant to have pinned, and the same-invoice combined-rail test I asked for is present at Executed locally on D-2 (isSplit too narrow) — RESOLVEDThe UNION at Medium findings — remain open, all non-blockingRe-checked on HEAD; none were addressed in
Low
Follow-ups correctly routedD-1 → #1895 (mark-as-claimed is invoice-scoped, HIGH), D-3 → #1896, M-1 → #1897. Filing rather than in-lining these was the right call — #1895 in particular is a distinct correctness bug in I'll also repeat the standing follow-up from my first review: collapse the Wiki — updated in this reviewThe PO was right that
Two notes on that push:
CIAt review time |
|
[product-owner] Re-review — Story #1891 (32 acceptance criteria) @
|
| Section | Previous | Now |
|---|---|---|
| §1 CSP bug (5 AC) | 5/5 | 5/5 (untouched by the fix round) |
| §2 Chip sizing (4 AC) | 4/4 | 4/4 |
| §3 Expandable rows (11 AC) | 11/11 | 11/11 (re-verified against the new dual rendering) |
| §4 Deposit budget source (12 AC) | 11/12 | 12/12 |
AC 4.6 — verified fixed
splitByDepositsExcludingTagged now keeps all deposits in the residual denominator and filters tagging only at the depositFractions emission step:
// residual: ALL deposits (tagged or not), refund-type excluded — identical to legacy splitByDeposits
const totalDepositTypeAmount = deposits.filter((d) => d.entryType !== 'refund')…
// Rail A emission: untagged only
const depositFractions = deposits.filter((d) => !d.isTagged).map(…)That is exactly the add→redirect semantics the AC asks for: the tagged deposit's money leaves Rail A and is re-added whole by Rail B, instead of being added twice. I re-derived all three numbers from my previous review against the committed code:
- Canonical repro (€1000 pending invoice, €1000 line → A, €400 paid deposit tagged to A): residual 0.6 → Rail A 600 + Rail B 400 = €1000 on a €1000 invoice.
sourceReportService.test.tsassertstoBeCloseTo(1000). - Two-source case: residual 0.85 → A = 510 + 150 = 660, B = 340. The test now also carries an explicit conservation assertion (
660 + 340 === 1000, not 1150) — good, that is the invariant that was missing. - Deposit tagged to a source with zero lines on the invoice: Rail A 0, Rail B 100% — AC 4.6's "including when the invoice has no budget lines for that source at all" still holds, and the user's explicitly-accepted cross-source contradiction is preserved. Covered by two new
getSourceReportregression tests.
I also checked the refund path, which the fix could have disturbed: refund-type entries are still filtered out of totalDepositTypeAmount, and a tagged refund is excluded from depositFractions and re-added signed by sumTaggedDepositContributions — so the #1876 rule (refunds stored positive, negated at aggregation, never reducing the residual) is intact for both tagged and untagged refunds. Untagged inputs are now strictly identical to splitByDeposits, so AC 4.7 is stronger than before, not weaker.
All four previous MUST FIX items are done:
- Stale KNOWN-BUG docstrings —
reportWizardExpansion.spec.ts(:28, the:43block,:516) andReportWizardPage.ts:75now read as regression guards for the fixed Fully-excluding all budget lines on an invoice makes it disappear from the report wizard's step-3 list #1892, with the corrected filter expression quoted. Comments only; no assertion changed. - The two defect-encoding tests — renamed, re-derived, and the unresolved
— wait pending IS in the claim slice) —fragment is gone.depositAggregateUtils.test.tsresiduals are now0.7/0.5with the reasoning spelled out. Corrected in the direction the source-of-truth hierarchy demands (spec wins over test). - Coverage gap closed —
budgetSourceService.test.tsgains the canonical same-invoice case (Rail A 600 unclaimed + Rail B 400 claimed = paid 1000, "NOT 1400") plus an untagged legacy control on identical fixture shape. That is the case the old "combine additively" test dodged by using two different invoices. - Focus nit — properly fixed, not papered over:
lastExpandedIdRefis only written on an open transition, and the effect no-ops unless that id is still expanded. Collapsing a row can no longer yank focus into an unrelated open panel.
All 6 Jest shards, Static Analysis, Coverage Report and Trailer Check are green on 74289586, so these are verified-passing numbers, not just verified-by-reading.
No-regression check on §3 (the dual desktop/mobile rendering)
This was my main concern, since the mobile card list is a second DOM copy of the sub-tables. Re-verified:
- AC 3.4 holds by construction. Both the desktop
<td>checkbox and the mobile card checkbox call the identicalonToggleLine(line.id, !e.target.checked)— there is no second exclusion path that could diverge fromapplyLineExclusions. ThemockGenerateReportPdfassertions inReportWizardPage.test.tsxare untouched, so the exclusion → row amount → running total → regenerated PDF chain is still proven end-to-end. - No duplicated accessible names.
.mobileCardListisdisplay: noneby default and.tableWrapperisdisplay: noneundermax-width: 767px—display: noneremoves the subtree from the accessibility tree, so exactly one instance of each control is exposed at any viewport. The duplication is a jsdom-only artifact, correctly handled by thegetAllByRole(...)[0]disambiguation in74289586(with an explanatory comment, so the next reader doesn't "simplify" it back). - AC 2.x is not touched by the mobile work.
.tableWrapperis used only inside the expansion panel (lines 293 and 434) — the step-3 invoice list itself is a CSS grid, not a table, so it does not vanish on mobile..statusChip { justify-self: start }has no media-query override in the file, so the chip fix holds identically at all three viewports (AC 2.4). - AC 3.8 fields all present on mobile — amount, status, dates, entry type, allocated source.
- AC 3.10 not re-broken. The mobile card negates
deposit.amountfor refunds, which matches the desktop cell exactly and is the correct Deposit refunds with negative claim adjustments #1876 display rule for the deposits sub-table. The invoice row'sallocatedAmountis still read straight through with no negation. Both rules coexisting correctly in one component is the thing that caught PR feat(reports): add bank report wizard with client-side PDF pipeline #1887 four times — good to see it right here. - AC 3.11 re-verified programmatically on the committed tree, including the newly-referenced keys: all 37 literal
sourceReports.*keys plussources.lines.invoiceStatus.*resolve inen/budget.json, and all 116sourceReports.*keys have German counterparts (zero missing). <a href>→<Link to>is a correctness improvement (full page reload → SPA navigation) andBadgeStyles.info→BadgeStyles.iblUnassignedreuses the existing semantic class rather than inventing one. Both fine.
The isSplit widening is in scope and I endorse it. An invoice funded from source A via a tagged deposit and source B via lines genuinely is split across sources — under the old line-only query the bank-facing PDF would silently omit the split footnote for exactly the configuration this story introduced. It stays server-derived (a UNION sub-query), so AC 4.12 holds, and the 5 new regression tests pin all four combinations (lines+deposit different sources, deposits-only two sources, untagged, same-source, single tagged) including the negative cases. This narrows my recorded contract fact — the field now means "funding spans 2+ distinct sources across budget lines and tagged deposits"; I have updated agent memory accordingly.
MUST FIX before merge (documentation — no re-review needed)
The wiki is still on the pre-fix description. origin/master is at e189ce9 and both of these lines are unchanged there. AC 4.11 requires the shared types and the API Contract page to move together; the shared type's JSDoc was updated in this PR, the wiki half is outstanding:
API-Contract.md:4186(Rail A) — "deposits with non-nullbudget_source_idare excluded from the pro-rata pool entirely. The residual (unaccounted invoice portion) uses the remaining deposits" now describes the defect, not the code. Tagged deposits are excluded from the emitted deposit fractions but are subtracted from the residual denominator. I understand the product-architect is already handling this one.API-Contract.md:3609(isSplit) — still reads "True iff this invoice's budget lines reference more than one distinct budget source". This is a second, separate correction that may not be on the architect's list — please make sure it lands with the Rail A edit. (Line 3607'sallocatedAmountwording is still accurate as written.)
Nits (non-blocking, your call)
- Mobile omits the
—placeholder for an untagged deposit's Allocated Source, where the desktop table renders it. Defensible card-layout convention (cards drop empty fields), and no AC mandates the placeholder — flagging only so the asymmetry is deliberate rather than accidental. getByText→getAllByText(...).length > 0is the right shape for the dual DOM but drops the "exactly one" guarantee. If you want the strictness back later, scoping withwithin(desktopTable)would keep both properties. Not worth churning now.
CI
Quality Gates is still in_progress on 74289586 (Docker job pending, E2E not yet reported) — my approval is on the acceptance criteria and is not a waiver of the gate. The evidence that matters for the blocker is already in: all 6 unit shards green, which is where the corrected 1000 / 660 / 340 / 0.7 / 0.5 assertions live.
Unchanged from my last review and still owed before this mini-epic is promoted: the E2E shard 5 pre-existing failures (invoices.spec.ts "Effective Amount" #1876-era flake, dashboard.spec.ts "card is dismissed") must be triaged, since E2E Gates is required on main. Not attributable to this PR.
Thanks for taking the residual fix at the root rather than special-casing the report path — doing it in the shared helper is what keeps AC 4.8's consistency requirement true for budgetSourceService and GET /api/source-reports simultaneously. Nice work on #1895/#1896/#1897 being filed rather than folded in; keeping claim close-out out of this PR was the right scope call.
Wiki master moved e189ce9 -> 616bc90 (docs(api): correct Rail A residual semantics and widen isSplit definition), addressing the two MUST FIX doc gaps from the PR #1894 (#1891) approval: API-Contract.md Rail A residual-denominator text and the isSplit UNION widening. Includes reviewer agent-memory ride-alongs from the PR #1894 review cycle (product-owner and product-architect). Co-Authored-By: Claude product-architect (Opus 4.6) <noreply@anthropic.com> Co-Authored-By: Claude product-owner (Opus 4.6) <noreply@anthropic.com>
|
🎉 This PR is included in version 2.13.0-beta.30 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
frameSrcdirective to allowblob:so the report wizard's pdfmake-generated preview actually renders in its iframe (was silently blocked)ReportInvoiceList), backed by a richerSourceReportInvoicepayload (budgetLines/deposits)invoice_deposits.budget_source_id(migration 0044) so a deposit can be tagged directly to a budget source, with a default-source-selection heuristic inInvoiceDepositsSectionFixes #1891
Fixes #1892
Fixes #1893
Test plan
reportExclusions,ReportInvoiceList,InvoiceDepositsSection,depositAggregateUtils,budgetSourceService,sourceReportService,invoiceDepositService, migration 0044,helmetPlugine2e/tests/budget/reportWizardExpansion.spec.ts— real-browser verification of the hardened CSPblob:preview fix plus expandable rows and deposit source allocationCo-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.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