You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As a homeowner generating a report for a bank, I want the documents embedded in the report to be selected by a document-tier rule that matches the report's purpose so that a claim report never ships a quotation to the bank and a proof-of-funds report only ships actual invoices.
Priority: Should Have Parent Epic: none — part of the parent-less Bank Report Wizard cluster (#1876–#1879, #1898–#1901, #1923–#1925). Found during: user review of generated reports (2026-08-01).
Problem
Documents linked to an invoice carry an attachmentType of quotation | deposit | invoice (nullable) — server/src/db/schema.ts L654, surfaced on the report response as SourceReportDocument.attachmentType (shared/src/types/sourceReport.ts L42).
Today the report service filters an invoice's documents by stage matching: it derives a set of stages from the invoice's own status slice and keeps only documents whose type is in that set (server/src/services/sourceReportService.ts step h, ~L286–339):
This is per-invoice and driven by invoice status, not by what the report is for. Two consequences the user hit:
A claim report (targetStatuses = pending, paid) can embed a quotation whenever a still-quotation-status invoice contributes to the slice. Sending a quotation to a bank as evidence of a claim is wrong — a quotation is not proof of anything owed.
A proof-of-funds report (targetStatuses = claimed) can embed deposit documents alongside invoices, when the point of that report is to evidence actual invoiced spend.
The behaviour is also hard to explain to a user: what gets attached depends on a per-invoice status derivation the user never sees.
Attachment types form an ordered tier reflecting how strong a piece of evidence is:
Tier
attachmentType
1 (weakest)
quotation
2
deposit
3 (strongest)
invoice
Each report type declares a tier floor, and a document is embedded if its tier is at or above that floor:
Report type
Tier floor
Embeds
budget-overview
quotation (1)
quotation, deposit, invoice
claim
deposit (2)
deposit, invoice — neverquotation
proof-of-funds
invoice (3)
invoice only
The rule depends only on the report type and the document's own type. It no longer consults the invoice's status, the deposit split, or targetStatuses.
attachmentType: null — the decision
null is treated as tier 3 (invoice). Consequently a null-typed document is embedded in every report type.
Rationale:
Null links are legacy/ambiguous data, not known-weak evidence. The invoice-creation Paperless picker hard-sets 'invoice'; nulls come from pre-Source contact fields, household sender setting & document attachment typing #1877 links and from the invoice detail page's "Add Document" picker when the user did not choose a type. In practice these are overwhelmingly actual invoices.
Treating null as the lowest tier would silently drop attachments from claim and proof-of-funds reports for existing data — a regression that produces a report missing its evidence, which is worse than an over-inclusive one the user can deselect.
Treating null as tier 3 is exactly no-regression for null links (they pass today too) while still delivering the whole point of the change: typed quotations stop appearing in claim reports.
Open issue #1888 ("Stage-matched attachment indicator in the report wizard invoice list") defers precisely on the question "what does attachmentType: null mean?" — its proposal was "null matches any stage".
This issue supersedes that design question. The ruling above (null = tier invoice) is now the project's answer, and #1888 must adopt it rather than re-deciding. What remains genuinely open in #1888 is the indicator's visual vocabulary in step 3 (matched vs. attached-but-not-matched vs. none) and its accessible naming — that is not resolved here.
Do not close #1888. When this lands, #1888 should be re-scoped to the indicator presentation only, with its null-handling AC replaced by a reference to the tier rule shipped here.
Acceptance Criteria
1 — Tier floors. Given a budget source with invoices carrying documents of all three attachmentType values, When a report is generated, Then the documents present on each invoice in the report response are exactly those whose tier is at or above the report type's floor: budget-overview → quotation+deposit+invoice; claim → deposit+invoice; proof-of-funds → invoice only.
2 — Quotations never reach a claim report. Given an invoice with a document typed quotation, When a claim report is generated, Then that document appears nowhere in the report: not in the invoice's documents array, not as an embedded appendix PDF, not in the step-3 attachment indicator, and not in the appendix footnote numbering.
3 — Deposits never reach a proof-of-funds report. Given an invoice with a document typed deposit and no invoice-typed document, When a proof-of-funds report is generated, Then that invoice reports zero documents and renders the existing "no document" treatment.
4 — Null is tier invoice. Given an invoice whose only linked document has attachmentType: null, When a report of any of the three types is generated, Then that document is included — matching current behaviour exactly, with no regression for existing data.
5 — Invoice status is irrelevant to document selection. Given two invoices carrying identically typed documents but different invoice statuses (e.g. one quotation, one paid) that both contribute to the same report slice, When the report is generated, Then both invoices' documents are filtered identically — the selection depends only on report type and document type.
6 — Which invoices appear is unchanged. Given any budget source, When a report of each type is generated, Then the set of invoices in the report, their allocated amounts, isSplit values, deposit rows, budget lines, summary totals, and footnotes are byte-for-byte identical to current beta behaviour. Only each invoice's documents array may differ.
7 — One source of truth: list and PDF agree. Given any report, When the wizard's step-3 invoice list and the exported PDF are compared, Then they reflect the same document set for every invoice — the filter is applied once, server-side, and both consumers read the filtered result. It must be impossible for step 3 to show an attachment indicator for a document the PDF does not embed, or vice versa.
8 — Appendix and footnotes follow the filter. Given a report where documents were excluded by the tier rule, When the PDF is generated, Then appendix numbering, the per-row attachments note, and the skipped-document footnotes count only the documents that passed the filter — excluded documents produce no "skipped" footnote (they were never candidates).
9 — Tier model is expressed once. Given the codebase after this change, When a reader looks for the tier ordering and the per-report-type floor, Then both are defined in a single named, documented place and referenced everywhere else — no report type's floor is hard-coded at a second site.
10 — Documented. The tier model, the per-report-type floors, and the null = tier invoice ruling are documented on the wiki (API Contract entry for the source-report endpoint, and/or the relevant ADR), so the rule is discoverable without reading sourceReportService.ts.
11 — Tests. Unit/integration tests in server/src/services/sourceReportService.test.ts cover, per report type: each of the three typed documents, a null-typed document, an invoice with a mix of types, and an invoice whose every document is filtered out. Tests assert the tier outcome directly, not the removed stage-derivation internals.
Notes
Primary file: server/src/services/sourceReportService.ts step h (~L286–339). The stages derivation and its dependence on splitsByInvoiceId / railBContributions / targetStatuses for document filtering is removed by this change. splitsByInvoiceId is still used elsewhere for amount computation — do not delete it.
No client-side filtering exists today — client/src/lib/reportPdf/merge.ts embeds whatever invoice.documents contains, and client/src/components/reports/ReportInvoiceList.tsx lights its indicator on documents.length > 0. Keeping the filter server-side is what makes AC7 hold for free; do not add a second filter on the client.
Scope guard: this issue does not add UI for choosing which documents to attach, does not change the attachDocuments on/off setting, and does not backfill attachmentType on existing links.
[product-owner]
As a homeowner generating a report for a bank, I want the documents embedded in the report to be selected by a document-tier rule that matches the report's purpose so that a claim report never ships a quotation to the bank and a proof-of-funds report only ships actual invoices.
Priority: Should Have
Parent Epic: none — part of the parent-less Bank Report Wizard cluster (#1876–#1879, #1898–#1901, #1923–#1925).
Found during: user review of generated reports (2026-08-01).
Problem
Documents linked to an invoice carry an
attachmentTypeofquotation|deposit|invoice(nullable) —server/src/db/schema.tsL654, surfaced on the report response asSourceReportDocument.attachmentType(shared/src/types/sourceReport.tsL42).Today the report service filters an invoice's documents by stage matching: it derives a set of stages from the invoice's own status slice and keeps only documents whose type is in that set (
server/src/services/sourceReportService.tsstep h, ~L286–339):This is per-invoice and driven by invoice status, not by what the report is for. Two consequences the user hit:
targetStatuses=pending,paid) can embed a quotation whenever a still-quotation-status invoice contributes to the slice. Sending a quotation to a bank as evidence of a claim is wrong — a quotation is not proof of anything owed.targetStatuses=claimed) can embed deposit documents alongside invoices, when the point of that report is to evidence actual invoiced spend.The behaviour is also hard to explain to a user: what gets attached depends on a per-invoice status derivation the user never sees.
Product decision — tiering replaces stage matching
Attachment types form an ordered tier reflecting how strong a piece of evidence is:
attachmentTypequotationdepositinvoiceEach report type declares a tier floor, and a document is embedded if its tier is at or above that floor:
budget-overviewquotation,deposit,invoiceclaimdeposit,invoice— neverquotationproof-of-fundsinvoiceonlyThe rule depends only on the report type and the document's own type. It no longer consults the invoice's status, the deposit split, or
targetStatuses.attachmentType: null— the decisionnullis treated as tier 3 (invoice). Consequently a null-typed document is embedded in every report type.Rationale:
'invoice'; nulls come from pre-Source contact fields, household sender setting & document attachment typing #1877 links and from the invoice detail page's "Add Document" picker when the user did not choose a type. In practice these are overwhelmingly actual invoices.Relationship to #1888
Open issue #1888 ("Stage-matched attachment indicator in the report wizard invoice list") defers precisely on the question "what does
attachmentType: nullmean?" — its proposal was "null matches any stage".This issue supersedes that design question. The ruling above (
null= tierinvoice) is now the project's answer, and #1888 must adopt it rather than re-deciding. What remains genuinely open in #1888 is the indicator's visual vocabulary in step 3 (matched vs. attached-but-not-matched vs. none) and its accessible naming — that is not resolved here.Do not close #1888. When this lands, #1888 should be re-scoped to the indicator presentation only, with its null-handling AC replaced by a reference to the tier rule shipped here.
Acceptance Criteria
attachmentTypevalues, When a report is generated, Then the documents present on each invoice in the report response are exactly those whose tier is at or above the report type's floor:budget-overview→ quotation+deposit+invoice;claim→ deposit+invoice;proof-of-funds→ invoice only.quotation, When aclaimreport is generated, Then that document appears nowhere in the report: not in the invoice'sdocumentsarray, not as an embedded appendix PDF, not in the step-3 attachment indicator, and not in the appendix footnote numbering.depositand noinvoice-typed document, When aproof-of-fundsreport is generated, Then that invoice reports zero documents and renders the existing "no document" treatment.invoice. Given an invoice whose only linked document hasattachmentType: null, When a report of any of the three types is generated, Then that document is included — matching current behaviour exactly, with no regression for existing data.quotation, onepaid) that both contribute to the same report slice, When the report is generated, Then both invoices' documents are filtered identically — the selection depends only on report type and document type.isSplitvalues, deposit rows, budget lines, summary totals, and footnotes are byte-for-byte identical to currentbetabehaviour. Only each invoice'sdocumentsarray may differ.null= tierinvoiceruling are documented on the wiki (API Contract entry for the source-report endpoint, and/or the relevant ADR), so the rule is discoverable without readingsourceReportService.ts.server/src/services/sourceReportService.test.tscover, per report type: each of the three typed documents, anull-typed document, an invoice with a mix of types, and an invoice whose every document is filtered out. Tests assert the tier outcome directly, not the removed stage-derivation internals.Notes
server/src/services/sourceReportService.tsstep h (~L286–339). Thestagesderivation and its dependence onsplitsByInvoiceId/railBContributions/targetStatusesfor document filtering is removed by this change.splitsByInvoiceIdis still used elsewhere for amount computation — do not delete it.SourceReportType = 'budget-overview' | 'claim' | 'proof-of-funds'(shared/src/types/sourceReport.tsL5).AttachmentTypeis'quotation' | 'deposit' | 'invoice'(shared/src/types/document.ts).client/src/lib/reportPdf/merge.tsembeds whateverinvoice.documentscontains, andclient/src/components/reports/ReportInvoiceList.tsxlights its indicator ondocuments.length > 0. Keeping the filter server-side is what makes AC7 hold for free; do not add a second filter on the client.attachDocumentson/off setting, and does not backfillattachmentTypeon existing links.