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 managing a construction project, I want to see every open invoice and open deposit in one filtered view on the Invoices page so that I know exactly what I still have to pay and what is already overdue, without opening each invoice one by one.
Parent Epic: none — standalone story (all epics are closed; this is a budget/invoice backlog item) Priority: Should Have
Problem
Deposits are only visible on InvoiceDetailPage — one invoice at a time. There is no cross-invoice deposit view anywhere in the application, so a deposit whose due date has passed is invisible unless the user happens to open that specific invoice. GET /api/invoices returns deposits: [] on every list row and accepts only a single status value, so this cannot be solved client-side.
Scope
Extend the existing Invoices page (/budget/invoices) with a "Show only open items" toggle. Open deposits appear as expandable child rows under their parent invoice.
/budget/invoices [x] Show only open items
▾ INV-204 Muster Bau 2026-08-12 €15,000 pending
└ Deposit 1/3 2026-08-12 €5,000 OVERDUE
└ Deposit 2/3 2026-09-12 €5,000 pending
INV-198 Elektro X 2026-08-20 €2,310 pending
Definition of "open" (fixed — not open for re-litigation)
status === 'pending', applied independently to invoices and to deposits. Explicitly excludes'quotation', 'paid', and 'claimed'. Overdue (pending + dueDate in the past) is a flag, not a status and not a separate bucket — overdue items remain open items.
Out of scope
Any new page, route, or navigation tab.
Dashboard changes (InvoicePipelineCard is untouched).
Treating quotation invoices as open in their own right.
Creating, editing, or settling deposits from this view (detail page keeps that).
Changing the meaning of finalPaymentAmount, the existing summary buckets, or any existing status semantics.
Notifications, reminders, or emails about overdue items.
Acceptance Criteria
A. Server-side open-items filtering
AC1 — Given the invoice list endpoint, when an open-items filter is requested, then the response contains exactly those invoices that either (a) have status === 'pending', or (b) have at least one deposit with status === 'pending' (of either entryType). Invoices matching neither are absent.
AC2 — Given an invoice with status === 'quotation' that has a pending deposit, when the open-items filter is applied, then that invoice is returned (its pending deposit is an open item and must be visible), and the invoice's own amount contributes nothing to the open totals of AC10/AC11.
AC3 — Given the open-items filter is applied, when the response is returned, then each returned invoice carries its deposits in the deposits field with the field's existing documented meaning unchanged (the invoice's deposit entries) — the field is not silently redefined to mean "pending deposits only". Filtering to pending for display is the client's job (AC7).
AC4 — Given the open-items filter is applied, when pagination is computed, then pagination.totalItems and totalPages count invoices only. Child deposit rows never consume page capacity, and a page of size 25 returns at most 25 invoices regardless of how many deposits they carry.
AC5 — Given the endpoint receives the open-items filter parameter, when the request is validated, then it is accepted (the querystring schema is additionalProperties: false today and currently rejects it). Requests without the parameter behave exactly as before.
B. The toggle and its state
AC6 — Given the user is on /budget/invoices, when they activate "Show only open items", then the list re-queries with the open-items filter, the URL gains a query parameter representing the toggle, and a full page reload or a browser Back/Forward to that URL restores the toggle in the same state.
AC7 — Given the toggle is ON, when a status column filter is active or is subsequently chosen, then the two cannot contradict each other: turning the toggle ON clears any active status filter from the table state and URL and disables the status filter control while ON; choosing a status filter turns the toggle OFF. (Decision: mutual exclusivity, because "open" is itself a status predicate.)
AC8 — Given the toggle is ON, when any other filter, search, or sort is applied, then it composes with the open-items filter rather than replacing it (e.g. open items for one vendor).
C. Rows and child rows
AC9 — Given the toggle is ON, when the list renders, then each returned invoice is a parent row and each of its pending deposits is a child row beneath it. Non-pending deposits of that invoice are not rendered as child rows. A parent with no pending deposits renders as a plain row with no expand control.
AC10 — Given a parent row exists only because of its pending deposits (its own status is not pending — see AC2), when it renders, then it is visually and programmatically marked as a container that is not itself an open item, and its own status badge shows its real status.
AC11 — Given a parent row has pending deposits, when the list first renders in open-items mode, then it is expanded by default; the user can collapse and re-expand it, and this expansion state is component-local (it is not written to the URL).
AC12 — Given the toggle is OFF, when the list renders, then there are no child rows and the page behaves exactly as it does today.
D. Amounts — the double-counting trap
AC13 — Given deposits carve up the invoice total (Σ deposits ≤ invoice.amount), when parent and child amounts are displayed, then the UI makes clear that child amounts are part of the parent amount and not additional to it. A user must not be able to read the screen as €15,000 + €5,000 + €5,000.
AC14 — Given the toggle is ON, when the list renders, then each parent row shows an open amount for that invoice, defined as: (invoice.status === 'pending' ? max(0, invoice.amount − Σ deposit-type entries of any status) : 0) + Σ (pending deposit-type entries), i.e. the still-unpaid final payment plus the still-unpaid deposits. Pending refunds are excluded from this figure (see AC17).
AC15 — Given a set of open invoices spanning multiple pages, when the per-invoice open amounts of AC14 are summed across the whole result set, then the sum equals the open payable total of AC16 exactly (no double counting, no omission).
AC16 — Given the page header summary, when the open-items view is available, then an open payable total (count + amount) is shown, computed as in AC14 summed over all invoices. This figure is global / filter-independent, matching the established behaviour of the existing summary tiles, and therefore does not change when the toggle or any filter is applied.
AC17 — Given the existing summary.pending bucket nets pending refunds against payables, when the open payable total is computed, then it does not reuse that netted figure unchanged: pending refunds are excluded from the payable total and reported separately (AC19).
E. Refunds (entryType: 'refund')
AC18 — Given a deposit with entryType === 'refund' and status === 'pending', when the open-items filter is applied, then it is an open item (money owed to the user) and renders as a child row that is visually distinguished from a payable deposit, signed so the direction of money is unambiguous.
AC19 — Given pending refunds exist, when totals are shown, then the refund amount is reported as its own figure ("refunds due to you") and is never summed naively into the open payable total of AC16.
AC20 — Given an invoice whose only open entry is a pending refund, when the open-items filter is applied, then the invoice appears with that refund as a child row, and its open payable amount (AC14) is 0.
F. Overdue
AC21 — Given an open item (invoice or deposit) whose dueDate is strictly before today, when it renders, then it is flagged as overdue by a visual treatment plus an accessible text/label — colour alone is not sufficient — while keeping its pending status badge. No new status value is introduced anywhere.
AC22 — Given the toggle is ON and the user has not chosen an explicit sort, when the list is ordered, then invoices are sorted ascending by their earliest open due date, defined as the minimum of the invoice's own dueDate (when the invoice is pending) and the dueDates of its pending deposits. Overdue items therefore appear first, without being placed in a separate bucket or given a separate status.
AC23 — Given an open invoice with a null dueDate and no pending deposits with due dates, when the default open-items ordering of AC22 is applied, then it sorts last, after all dated items.
AC24 — Given the user chooses an explicit sort column, when the list re-renders, then that sort wins over the AC22 default and the toggle stays on.
AC25 — Given a parent invoice that is not itself overdue but has an overdue pending deposit, when the list renders, then the overdue condition is discoverable on the collapsed parent row (the user must not have to expand it to learn something is overdue).
G. Empty state
AC26 — Given the toggle is ON and no open items exist, when the list renders, then a distinct "nothing open" empty state is shown (positive framing — nothing is outstanding), and it does not show the existing "add your first invoice" copy or its call-to-action, which would be wrong when invoices exist but none are open.
AC27 — Given the toggle is ON and additional filters exclude everything, when the list renders, then the existing "no results for these filters" treatment applies rather than the AC26 empty state.
H. Responsive
AC28 — Given a mobile viewport where the page renders as cards rather than a table, when the toggle is ON, then open deposits render as clearly nested entries within their parent invoice card, with the same expand/collapse affordance as AC11, and no horizontal overflow at 320px width.
AC29 — Given tablet and desktop viewports, when the toggle is ON, then parent/child indentation is visually unambiguous and the page passes the project's dark-mode expectations in both themes.
I. Accessibility and i18n
AC30 — Given the expand/collapse control on a parent row, when a keyboard user reaches it, then it is focusable with a visible focus indicator, operable by Enter/Space, and exposes aria-expanded reflecting its true state; child rows are programmatically associated with their parent.
AC31 — Given the "Show only open items" toggle, when a screen-reader user encounters it, then it has an accessible name and its state is announced.
AC32 — Given all new user-facing strings, when they are added, then they live in the budget namespace under invoices.* (English keys only from the implementer), every t() path resolves in client/src/i18n/en/budget.json, and German translations are added by the translator. Amounts and dates use formatCurrency / formatDate from client/src/lib/formatters.ts — never raw Intl or toLocaleDateString.
J. Regression guards
AC33 — Given the invoice list is requested without the open-items filter, when the response is compared to current behaviour, then it is unchanged: same rows, same pagination, same summary buckets, and deposits still [] on list rows.
AC34 — Given DataTable gains an expandable-child-row capability, when any other page that uses DataTable renders, then its markup and behaviour are unchanged — the new capability is opt-in and inert when unused.
AC35 — Given finalPaymentAmount and computeFinalPaymentAmount, when this story ships, then their formula and meaning are unchanged; the new open-amount computation of AC14 is additive and does not replace or redefine them.
Notes
Is a new "outstanding amount" computation in scope? — Yes, a narrow one.
Invoice.finalPaymentAmount is not "amount still outstanding". Confirmed in server/src/services/shared/depositAggregateUtils.ts → computeFinalPaymentAmount: it is max(0, amount − Σ deposit-type entries of ANY status − Σ received (paid/claimed) refunds). It subtracts pending deposits, so on an invoice with unpaid deposits it under-reports what is owed, and it is clamped at zero. Nothing in the codebase computes a true outstanding figure.
So AC14/AC15/AC16 require a new, narrowly scoped per-invoice open-amount computation. It must be additive (AC35) — finalPaymentAmount has other consumers and its meaning must not shift.
Note for the implementer: aggregateInvoiceStatusBreakdown already produces a deposit-aware summary.pending bucket that is close to the needed figure (residual of pending invoices + pending deposits, refund-signed). It is deliberately not reusable as-is, because it nets pending refunds against payables — precisely the naive summing AC17/AC19 forbid. Derive alongside it, do not repurpose it.
Decisions made while drafting (flagged for the user — cheap to reverse before implementation)
AC2 — quotation invoices as containers. A quotation invoice with a pending deposit appears in the open list, because "see all open deposits" cannot be satisfied otherwise, and quotationCoveredByDeposits in the existing summary proves deposits on quotation invoices are a real, modelled case. The invoice itself contributes nothing to open totals and is marked as a container (AC10). Alternative if unwanted: restrict parents to status === 'pending', accepting that some open deposits become invisible.
AC7 — toggle and status filter are mutually exclusive. "Open" is itself a status predicate, so allowing both invites contradictory states like "open items that are paid".
AC16 — open totals are global, not filtered. Matches the deliberate existing behaviour of the page's summary tiles (invoiceService.listAllInvoices computes the summary filter-independently by design).
Technical constraints (for the architect / implementer, not decisions)
GET /api/invoices querystring is additionalProperties: false and accepts a single status enum — a new parameter must be added to the schema (AC5). Exact parameter name and response shape are product-architect's call.
List rows currently hardcode deposits: [] (invoiceService.listAllInvoices, "Map rows directly (not using toInvoice()) to avoid fetching full deposits in list").
DataTable (client/src/components/DataTable/DataTable.tsx) has no expandable-row support today. Per the Component Reuse Policy this must be an extension of the shared component, not a parallel implementation — which is why AC34 exists.
Mobile card rendering goes through DataTableCard; ordering is server-side SQL (ORDER BY on invoice columns only), so AC22's "earliest open due date" ordering is genuine server work.
Commit convention
This story has no parent epic, so /epic-close (the only workflow with a UAT step) will never run for it. Commits must use Refs #<this issue>, not Fixes #<this issue>, so a standalone /release cannot auto-close it before UAT. Acceptance is the board status, set by the product-owner.
As a homeowner managing a construction project, I want to see every open invoice and open deposit in one filtered view on the Invoices page so that I know exactly what I still have to pay and what is already overdue, without opening each invoice one by one.
Parent Epic: none — standalone story (all epics are closed; this is a budget/invoice backlog item)
Priority: Should Have
Problem
Deposits are only visible on
InvoiceDetailPage— one invoice at a time. There is no cross-invoice deposit view anywhere in the application, so a deposit whose due date has passed is invisible unless the user happens to open that specific invoice.GET /api/invoicesreturnsdeposits: []on every list row and accepts only a singlestatusvalue, so this cannot be solved client-side.Scope
Extend the existing Invoices page (
/budget/invoices) with a "Show only open items" toggle. Open deposits appear as expandable child rows under their parent invoice.Definition of "open" (fixed — not open for re-litigation)
status === 'pending', applied independently to invoices and to deposits. Explicitly excludes'quotation','paid', and'claimed'. Overdue (pending+dueDatein the past) is a flag, not a status and not a separate bucket — overdue items remain open items.Out of scope
InvoicePipelineCardis untouched).quotationinvoices as open in their own right.finalPaymentAmount, the existingsummarybuckets, or any existing status semantics.Acceptance Criteria
A. Server-side open-items filtering
status === 'pending', or (b) have at least one deposit withstatus === 'pending'(of eitherentryType). Invoices matching neither are absent.status === 'quotation'that has a pending deposit, when the open-items filter is applied, then that invoice is returned (its pending deposit is an open item and must be visible), and the invoice's own amount contributes nothing to the open totals of AC10/AC11.depositsfield with the field's existing documented meaning unchanged (the invoice's deposit entries) — the field is not silently redefined to mean "pending deposits only". Filtering to pending for display is the client's job (AC7).pagination.totalItemsandtotalPagescount invoices only. Child deposit rows never consume page capacity, and a page of size 25 returns at most 25 invoices regardless of how many deposits they carry.additionalProperties: falsetoday and currently rejects it). Requests without the parameter behave exactly as before.B. The toggle and its state
/budget/invoices, when they activate "Show only open items", then the list re-queries with the open-items filter, the URL gains a query parameter representing the toggle, and a full page reload or a browser Back/Forward to that URL restores the toggle in the same state.C. Rows and child rows
pending— see AC2), when it renders, then it is visually and programmatically marked as a container that is not itself an open item, and its own status badge shows its real status.D. Amounts — the double-counting trap
Σ deposits ≤ invoice.amount), when parent and child amounts are displayed, then the UI makes clear that child amounts are part of the parent amount and not additional to it. A user must not be able to read the screen as€15,000 + €5,000 + €5,000.(invoice.status === 'pending' ? max(0, invoice.amount − Σ deposit-type entries of any status) : 0) + Σ (pending deposit-type entries), i.e. the still-unpaid final payment plus the still-unpaid deposits. Pending refunds are excluded from this figure (see AC17).summary.pendingbucket nets pending refunds against payables, when the open payable total is computed, then it does not reuse that netted figure unchanged: pending refunds are excluded from the payable total and reported separately (AC19).E. Refunds (
entryType: 'refund')entryType === 'refund'andstatus === 'pending', when the open-items filter is applied, then it is an open item (money owed to the user) and renders as a child row that is visually distinguished from a payable deposit, signed so the direction of money is unambiguous.0.F. Overdue
dueDateis strictly before today, when it renders, then it is flagged as overdue by a visual treatment plus an accessible text/label — colour alone is not sufficient — while keeping itspendingstatus badge. No new status value is introduced anywhere.dueDate(when the invoice is pending) and thedueDates of its pending deposits. Overdue items therefore appear first, without being placed in a separate bucket or given a separate status.dueDateand no pending deposits with due dates, when the default open-items ordering of AC22 is applied, then it sorts last, after all dated items.G. Empty state
H. Responsive
I. Accessibility and i18n
aria-expandedreflecting its true state; child rows are programmatically associated with their parent.budgetnamespace underinvoices.*(English keys only from the implementer), everyt()path resolves inclient/src/i18n/en/budget.json, and German translations are added by the translator. Amounts and dates useformatCurrency/formatDatefromclient/src/lib/formatters.ts— never rawIntlortoLocaleDateString.J. Regression guards
summarybuckets, anddepositsstill[]on list rows.DataTablegains an expandable-child-row capability, when any other page that usesDataTablerenders, then its markup and behaviour are unchanged — the new capability is opt-in and inert when unused.finalPaymentAmountandcomputeFinalPaymentAmount, when this story ships, then their formula and meaning are unchanged; the new open-amount computation of AC14 is additive and does not replace or redefine them.Notes
Is a new "outstanding amount" computation in scope? — Yes, a narrow one.
Invoice.finalPaymentAmountis not "amount still outstanding". Confirmed inserver/src/services/shared/depositAggregateUtils.ts→computeFinalPaymentAmount: it ismax(0, amount − Σ deposit-type entries of ANY status − Σ received (paid/claimed) refunds). It subtracts pending deposits, so on an invoice with unpaid deposits it under-reports what is owed, and it is clamped at zero. Nothing in the codebase computes a true outstanding figure.So AC14/AC15/AC16 require a new, narrowly scoped per-invoice open-amount computation. It must be additive (AC35) —
finalPaymentAmounthas other consumers and its meaning must not shift.Note for the implementer:
aggregateInvoiceStatusBreakdownalready produces a deposit-awaresummary.pendingbucket that is close to the needed figure (residual of pending invoices + pending deposits, refund-signed). It is deliberately not reusable as-is, because it nets pending refunds against payables — precisely the naive summing AC17/AC19 forbid. Derive alongside it, do not repurpose it.Decisions made while drafting (flagged for the user — cheap to reverse before implementation)
quotationinvoice with a pending deposit appears in the open list, because "see all open deposits" cannot be satisfied otherwise, andquotationCoveredByDepositsin the existing summary proves deposits on quotation invoices are a real, modelled case. The invoice itself contributes nothing to open totals and is marked as a container (AC10). Alternative if unwanted: restrict parents tostatus === 'pending', accepting that some open deposits become invisible.invoiceService.listAllInvoicescomputes the summary filter-independently by design).Technical constraints (for the architect / implementer, not decisions)
GET /api/invoicesquerystring isadditionalProperties: falseand accepts a singlestatusenum — a new parameter must be added to the schema (AC5). Exact parameter name and response shape are product-architect's call.deposits: [](invoiceService.listAllInvoices, "Map rows directly (not using toInvoice()) to avoid fetching full deposits in list").DataTable(client/src/components/DataTable/DataTable.tsx) has no expandable-row support today. Per the Component Reuse Policy this must be an extension of the shared component, not a parallel implementation — which is why AC34 exists.DataTableCard; ordering is server-side SQL (ORDER BYon invoice columns only), so AC22's "earliest open due date" ordering is genuine server work.Commit convention
This story has no parent epic, so
/epic-close(the only workflow with a UAT step) will never run for it. Commits must useRefs #<this issue>, notFixes #<this issue>, so a standalone/releasecannot auto-close it before UAT. Acceptance is the board status, set by the product-owner.