Skip to content

Aging Filters, Invoice Analysis and Supplier Statement Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A finance user filters AR aging by customer and amount and AP aging by supplier and amount; reads AP aging in six buckets (not due, 1–30, 31–60, 61–90, 91–180, over 180) or grouped by due year, with an Outstanding and an Overdue total; reads an Invoice analysis report (issued, collected for the month, accumulated, collection rate) behind its own permission, with an invoice export that defaults to the whole financial year; and produces an audit-grade supplier statement that mirrors the customer statement. The sales invoice list is verified for search, filter, sort and pagination.

Architecture: No new module. Aging filters and the AP scheme extend internal/platform/payables/aging.go and internal/platform/receivables/aging.go, served by internal/modules/reports/aging_handlers.go. Invoice analysis is a new read in internal/platform/receivables/analysis.go. The supplier statement is internal/platform/payables/statement.go, the AP mirror of receivables.StatementFor (spec D14). internal/platform/reporting/export gains invoice_analysis and supplier_statement. The web extends web/src/features/reports and web/src/features/payables. Nothing here writes a journal line.

Tech Stack: as the AR plan (2026-09-27-accounts-receivable.md).

Source: the owner’s handwritten change list and typed notes of 2026-10-05, and the wireframes agreed in the session of the same date. Template: 2026-09-27-accounts-receivable.md — where a step says “mirror AR statement”, open internal/platform/receivables/statement.go and follow it line for line, changing only what the step names.

Every task’s requirements implicitly include this section. They are the AR plan’s constraints, restated only where this plan touches them.

  • Migrations 01080–01079 only. Add {1080, 1079, "Aging filters, invoice analysis, supplier statement (2026-10-05 plan)"} to internal/kernel/db/migrationranges_test.go in the same commit as 01080.
  • Money never float. money.Amount in Go, NUMERIC(19,4), a string on the wire. The amount filter is parsed with money (never strconv.ParseFloat), compared with Cmp. The collection rate is a money.Rate string, computed server-side; the web only formats it (web/src/drift/nofloat.test.ts).
  • No new table. Everything reads existing tables, so no new RLS surface. Every SQL statement still names org_id = $n for each org-scoped table it touches (TestEveryTenantQueryCarriesAnExplicitOrgPredicate).
  • Whitelisted query parameters. Every new parameter is added to that handler’s allowQuery list. A parameter not on the list stays a 400.
  • Contract first. Every parameter and response field is in api/openapi.yaml in the same commit; make generate after every change; no hand-kept API inventory.
  • Permissions. Aging and statement reads need reports.read and the owner key (payables.invoice.read / receivables.invoice.read). The new report needs receivables.analysis.read. Every new export report is added to reports.OwnerReadPermission (TestEveryExportableReportNamesItsOwnerReadKey).
  • Business date is the organization’s day (clock.Today), never time.Now(). Month and year boundaries are the organization’s, not UTC’s.
  • i18n. Every UI string is a key in en, ms and zh (i18nparity.test.ts). Malay is genuine Malay.
  • Presence before absence, watched failing once (AGENTS.md). Each task with a guard has a Sabotage step.
  • Tests: run only the touched packages while iterating; one full go test -p 2 -count=1 ./... in the last task.
  • Never bare git stash. Commits end with the repository’s attribution line.

Plan-level choices. Items marked OWNER are defaults awaiting the owner; the task using one says so and keeps the value in one constant.

  • A1 Ruling: The aging filters are supplier_id / customer_id (uuid), min_outstanding, max_outstanding (money strings). The filter applies to a document row’s outstanding in the document’s own currency’s base (outstanding_base), and the totals are recomputed from the rows that remain, so what is shown always adds up (the old API’s behaviour for AR search).
  • A2 Ruling: min_outstanding > max_outstanding is 422 field=max_outstanding rule=range. A negative bound is allowed: a credit note’s remainder is negative.
  • A3 OWNER Ruling: AP aging gains scheme=extended (current, days_1_30, days_31_60, days_61_90, days_91_180, over_180). The default stays standard, so the public API, the existing export and AR are unchanged; the web asks for extended. A new type payables.ExtendedBucketAmounts is added; payables.BucketAmounts is untouched because AR shares it (R15). This amends Ruling P31 for AP only. The handwritten note said 90–120 and 120+; the typed note said 90–180. Default: the typed note. The edges live in one table, extendedEdges.
  • A4 Ruling: outstanding_total is the AP total; overdue_total is the total minus current (a document not yet due). Both are computed server-side in AgingAsOf.
  • A5 Ruling: group=due_year returns, per supplier, one amount per calendar year of due_on, and the totals per year. It is mutually exclusive with scheme=extended (422 field=group rule=exclusive).
  • A6 OWNER Ruling: Aging stays counted from the due date (P31). The handwritten “close by month, start counting from the first day of next month” is not applied; it is a separate ruling needing an owner answer (Task 0).
  • A7 Ruling: Invoice analysis reports, in base currency, for a period [from, to] (a year, or a month within it, by default the current financial year to today): issued = Σ gross_base of issued sales_invoice documents by invoice_date; credit notes = Σ issued customer_credit_note, shown on their own line and not netted (OWNER); collected = Σ base of customer_receipt documents in state received by receipt_date, net of voided (OWNER: receipt date, not allocation date); accumulated = collected from the start of the financial year to to; rate = collected ÷ issued as a percentage to one decimal (money.PercentOf, the one way a percentage of money is computed and sent), null when issued is zero. A month may exceed 100%; that is correct, not an error.
  • A8 Ruling: Permission receivables.analysis.read is registered with member.RegisterPermission; Owner and Finance roles receive it from a grants migration (the 00313 shape); a custom role gets it by hand and DEPLOY_NOTES.md says so.
  • A9 Ruling: The invoice export, if one exists, takes from/to and defaults to the organization’s current financial year when both are omitted. If none exists, Task 5 adds sales_invoice_register as an export report with the same default. Task 5 step 1 decides which, by reading the code.
  • A10 Ruling: payables.StatementFor mirrors R16 on control_kind = 'ap': lines of kind = 'normal', the supplier’s party_id, one currency (default the supplier’s), entry_date in [from, to], ordered entry_date, posted_seq, line_no; fx_difference lines excluded. The balance is credit-normal (a positive balance is money owed to the supplier). Opening = Σ(credit − debit) before from.
  • A11 Ruling: The statement closes with a tie-out: the closing balance equals the supplier’s sub-ledger balance (payables.SupplierBalance) and the report says so, with the difference. A non-zero difference is shown, never hidden; it also fails the drift test of Task 6.
  • A12 Ruling: Reversed and voided documents appear as their own lines (the reversal entry), never as a disappearing row. The statement lists the journal entry_no and the source kind so an auditor can trace a line to the ledger.
  • A13 Ruling: The statement export is a normal export record (kept seven years). Its header carries the export id, the organization’s legal name and registration number, the currency, the period, who generated it and when. The export record already stores the file’s SHA-256 (report_export.sha256, shown on the export and checked on download), so no second integrity mechanism is added: the file carries the export id, and the hash is read from the record. (Verified in Task 6 step 1.)
  • A14 OWNER Ruling: Sorting the sales invoice list by an arbitrary column conflicts with R17’s keyset on (created_at DESC, id DESC): page 2 of a list sorted by total would repeat or skip rows. Default: the cursor carries the sort key and id as the tiebreak, and the list accepts a fixed set of sort columns. Task 7 verifies what the list does today before changing it.

File Task Responsibility
01080_report_export_aging_analysis.sql 5, 6 report_export_report_known gains invoice_analysis, supplier_statement (and sales_invoice_register if Task 5 needs it).
01081_receivables_analysis_grants.sql 5 Owner and Finance roles get receivables.analysis.read.
Path Task Change
internal/platform/payables/aging.go 1, 2, 3 filter, ExtendedBucketFor, ExtendedBucketAmounts, overdue, year grouping
internal/platform/receivables/aging.go 1 filter
internal/modules/reports/aging_handlers.go 1, 2, 3 new params, allowQuery, DTOs
internal/platform/receivables/analysis.go 4 new
internal/platform/payables/statement.go 6 new
internal/platform/reporting/export/*, internal/modules/reports/permissions.go 5, 6 two reports, owner keys

web/src/features/reports/{APAgingView,ARAgingView}.tsx (matrix, filters, cards), web/src/features/reports/InvoiceAnalysisView.tsx (new), web/src/features/payables/SupplierStatement.tsx (new, beside the supplier drawer, mirroring CustomerStatement.tsx), locale files payables.json, receivables.json, reports.json in en/ms/zh.


Task 0 — Owner questions, ruled before code

Section titled “Task 0 — Owner questions, ruled before code”
  • Write docs/superpowers/briefs/2026-10-05-aging-owner-questions.md listing the OWNER rulings (A3, A6, A7 credit notes and collected basis, A14) with the default each task will use if unanswered.
  • Done when: the brief is committed and the defaults are named in the plan. The code does not wait for the answers; each default sits in one constant so changing it is one line.
  • 1.1 Read. internal/platform/payables/aging.go (AgingAsOf(ctx, tx, asOf, supplierID) already takes a supplier), internal/platform/receivables/aging.go, internal/modules/reports/aging_handlers.go, the allowQuery helper.
  • 1.2 Failing tests (presence first). Seed three suppliers with open invoices of known amounts. Test: no filter returns all three (presence); supplier_id returns one; min_outstanding/max_outstanding return the expected subset; the totals equal the sum of the returned rows; min > max is 422; a malformed amount is 422; a supplier_id belonging to another organization returns nothing (RLS plus explicit org_id). Same for AR with customer_id.
  • 1.3 Implement. Replace the *uuid.UUID argument with Filter{PartyID *uuid.UUID; Min, Max *money.Amount}. Apply the party filter in SQL (it already is) and the amount bound as the rows are read: the query already returns one row per open document, so there is nothing larger to push down. Recompute Suppliers / Customers rollups and Totals from the surviving rows.
  • 1.4 Handler and contract. Add the four parameters to allowQuery, to api/openapi.yaml, run make generate.
  • 1.5 Sabotage. Drop the amount predicate; the range test must go red and print which rows leaked. Restore. Write the red message in the commit body.
  • 1.6 Commit.

Task 2 — AP extended buckets, Outstanding and Overdue

Section titled “Task 2 — AP extended buckets, Outstanding and Overdue”
  • 2.1 Failing tests. A table test over ExtendedBucketFor(days) at every edge: 0 → current, 1, 30, 31, 60, 61, 90, 91, 180, 181. A document set that lands one document in each bucket; the totals per bucket; outstanding_total equals the sum; overdue_total equals the sum minus current.
  • 2.2 Implement. extendedEdges table, ExtendedBucketFor, ExtendedBucketAmounts with Add. scheme=extended selects it; standard is unchanged and its existing tests stay green untouched.
  • 2.3 Handler and contract. scheme enum, response fields outstanding_total, overdue_total. Update the ap_aging export to accept scheme; its default keeps the old columns.
  • 2.4 Drift test. Every bucket in Extended appears in the OpenAPI enum, the web key list and all three locale files (a test that reads them; assert it found a non-zero number of keys before asserting they agree).
  • 2.5 Sabotage. Move an edge by one day; the edge test goes red. Restore.
  • 2.6 Commit.
  • 3.1 Failing tests. Documents due in 2025 and 2026 yield two year columns; a supplier row carries one amount per year; totals per year; group=due_year with scheme=extended is 422.
  • 3.2 Implement in AgingAsOf as a second grouping over the same open-document set (one query, two projections), not a second query.
  • 3.3 Contract, regenerate, commit.
  • 4.1 Read. The sales document and receipt tables, receivables posting rules, the organization’s fiscal-year source.
  • 4.2 Failing tests. A fixture of invoices, a credit note and receipts across three months of two years. Assert issued, collected, accumulated and rate for a month, for a year, and for an empty period (rate is null, never a division error). A voided receipt is excluded (presence: it is counted before the void). A draft invoice is excluded. A document of another organization is excluded.
  • 4.3 Implement receivables.AnalysisFor(ctx, tx, from, to): three aggregate SELECTs, no rows loaded. The rate uses money.Rate.
  • 4.4 Route. GET /reports/invoice-analysis?year=&month=; gated by reports.read, receivables.analysis.read. Validate month 1–12 and year a four-digit year; reject month without year.
  • 4.5 Sabotage. Remove the permission gate; the route-permission test must go red. Restore.
  • 4.6 Commit.

Task 5 — Permission, export and invoice export default

Section titled “Task 5 — Permission, export and invoice export default”
  • 5.1 Decide A9. Search the export registry for an existing invoice export. Record the finding in the commit body. If absent, add sales_invoice_register.
  • 5.2 Register receivables.analysis.read; write 01081 (the 00313 shape); write 01080; add the migration range; make generate.
  • 5.3 Export. invoice_analysis (params year, month) and the invoice export (params from, to, default the current financial year when both are omitted). Add both to reports.OwnerReadPermission.
  • 5.4 Tests. The default range test (omitted dates resolve to the financial year, with a fixture that straddles a year boundary so a calendar-year default would fail); TestEveryExportableReportNamesItsOwnerReadKey stays green.
  • 5.5 Commit.
  • 6.1 Read. internal/platform/receivables/statement.go and its tests; the export record (does it store a content hash? A13). Record the answer.
  • 6.2 Failing tests. Mirror the AR statement tests on the AP control account: opening balance, lines in order, closing balance, a foreign-currency supplier in its own currency, fx_difference excluded, a reversed invoice shown as two lines, an aging block as of to (extended buckets), from > to refused. Tie-out drift test: for every supplier in the fixture, the closing balance of a statement from the beginning of time equals payables.SupplierBalance.
  • 6.3 Implement payables.StatementFor (A10–A12). Reuse the AR query shape, changing the control kind, the sign and the supplier join; do not fork the SQL logic.
  • 6.4 Route and export. GET /payables/suppliers/{id}/statement, export supplier_statement (from, to, supplier_id required, currency), owner key payables.invoice.read. The PDF carries the header, the page numbers, the tie-out line and the sign-off block.
  • 6.5 Sabotage. Flip the sign of the opening balance; the tie-out test goes red. Restore.
  • 6.6 Commit.

Task 7 — Sales invoice list: verify, then fix only what is missing

Section titled “Task 7 — Sales invoice list: verify, then fix only what is missing”
  • 7.1 Read the receivables invoice list handler, its cursor (cursor.go) and web/src/features/receivables list page.
  • 7.2 Record in the commit body what the list supports today for search, status filter, customer filter, date range, sort and paging. No change if all are present.
  • 7.3 If sort is arbitrary but the cursor is (created_at, id), write the failing test that pages through a list sorted by total and asserts no row repeats or is skipped, then apply A14.
  • 7.4 Commit (or record that nothing changed).
  • 8.1 AP aging view. Supplier × bucket matrix with the supplier filter, amount range, Days/Year switch, the two highlighted cards, expandable document rows, sortable total. Money through MoneyText; the rate through the existing percent formatter; no float.
  • 8.2 AR aging view. Customer filter and amount range on the existing matrix.
  • 8.3 Invoice analysis view with the year and month selects, cards, the rate bar and the monthly table; shown only to holders of the permission (hidden in the UI, enforced by the API).
  • 8.4 Supplier statement section in the supplier drawer with ExportMenu.
  • 8.5 i18n in en/ms/zh; parity test green.
  • 8.6 Tests (Vitest + MSW): filters send the right query string; a blank bound is omitted; Year mode renders year columns; an empty result shows the empty state; the analysis view is absent without the permission.
  • 8.7 Commit.
  • One full go test -p 2 -count=1 ./... and the web suite; make generate leaves git status clean.
  • docs/ROADMAP.md: tick the entry (text in ROADMAP-entry.md beside this plan).
  • docs/DEPLOY_NOTES.md: re-grant receivables.analysis.read to custom roles that should see invoice analysis; AP aging default is unchanged.
  • Performance note: run the aging query with EXPLAIN (ANALYZE, BUFFERS) against a 100k-line seeded organization; record the timing in the commit body. Add an index only if the plan shows a sequential scan on journal_line or ap_allocation. Not done: no organization of that size was seeded. The aging, analysis and register reads are set-based aggregates (one row per open document or per month); measure before adding an index.

  • Every new route is behind httpx.Protected and in api/openapi.yaml; api/public_routes.txt gains nothing.
  • A party id from another organization returns nothing, proven with a presence assertion first.
  • Amount filters and years are parsed and range-checked server-side; no SQL is built by string concatenation; every statement is parameterised.
  • Exports need reports.export plus the owner read key; the statement and analysis are not downloadable by a user who could not read them on screen.
  • CSV export neutralises cells beginning =, +, -, @ (verify the existing exporter does; add a test if not).
  • The permission is enforced by the API, not only hidden in the UI.

What was built, and where it differs from the plan

Section titled “What was built, and where it differs from the plan”
  • Task 1: payables.Filter and AgingFiltered (AR: receivables.AgingFiltered); the amount bound is applied as rows are read.
  • Task 2: payables/aging_extended.go (ExtendedBucketFor, ExtendedOf, OutstandingTotal, OverdueTotal). The ap_aging export also takes supplier_id, min_outstanding, max_outstanding, scheme; ar_aging takes customer_id, min_outstanding, max_outstanding.
  • Task 3: ByDueYear, from the same rows as the buckets (one query, two projections).
  • Task 4: receivables/analysis.go, route GET /reports/invoice-analysis, permission receivables.analysis.read.
  • Task 5: the repository had no invoice export, so sales_invoice_register was added; its range defaults to the fiscal year at request time and is stored explicitly on the export. Migrations 01080 (export CHECK) and 01081 (grants).
  • Task 6: payables/statement.go, route GET /payables/suppliers/{id}/statement and the supplier_statement export (CSV, XLSX, PDF through the export record, which carries the SHA-256). No separate print-PDF route was added.
  • Task 7: the list had paging, a customer, state and currency filter, and nothing else. Added q, from, to, sort, order; the cursor now carries the order that issued it (A14’s default).
  • Task 8: aging matrix and filters, invoice analysis tab, supplier statement section, list filters; en/ms/zh.