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.
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section. They are the AR plan’s constraints, restated only where this plan touches them.
- Migrations
01080–01079only. Add{1080, 1079, "Aging filters, invoice analysis, supplier statement (2026-10-05 plan)"}tointernal/kernel/db/migrationranges_test.goin the same commit as01080. - Money never float.
money.Amountin Go,NUMERIC(19,4), a string on the wire. The amount filter is parsed withmoney(neverstrconv.ParseFloat), compared withCmp. The collection rate is amoney.Ratestring, 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 = $nfor each org-scoped table it touches (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). - Whitelisted query parameters. Every new parameter is added to that handler’s
allowQuerylist. A parameter not on the list stays a 400. - Contract first. Every parameter and response field is in
api/openapi.yamlin the same commit;make generateafter every change; no hand-kept API inventory. - Permissions. Aging and statement reads need
reports.readand the owner key (payables.invoice.read/receivables.invoice.read). The new report needsreceivables.analysis.read. Every new export report is added toreports.OwnerReadPermission(TestEveryExportableReportNamesItsOwnerReadKey). - Business date is the organization’s day (
clock.Today), nevertime.Now(). Month and year boundaries are the organization’s, not UTC’s. - i18n. Every UI string is a key in
en,msandzh(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.
Rulings
Section titled “Rulings”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_outstandingis 422field=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 staysstandard, so the public API, the existing export and AR are unchanged; the web asks forextended. A new typepayables.ExtendedBucketAmountsis added;payables.BucketAmountsis 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_totalis the AP total;overdue_totalis the total minuscurrent(a document not yet due). Both are computed server-side inAgingAsOf. - A5 Ruling:
group=due_yearreturns, per supplier, one amount per calendar year ofdue_on, and the totals per year. It is mutually exclusive withscheme=extended(422field=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_baseof issuedsales_invoicedocuments byinvoice_date; credit notes = Σ issuedcustomer_credit_note, shown on their own line and not netted (OWNER); collected = Σ base ofcustomer_receiptdocuments in statereceivedbyreceipt_date, net of voided (OWNER: receipt date, not allocation date); accumulated = collected from the start of the financial year toto; rate = collected ÷ issued as a percentage to one decimal (money.PercentOf, the one way a percentage of money is computed and sent),nullwhen issued is zero. A month may exceed 100%; that is correct, not an error. - A8 Ruling: Permission
receivables.analysis.readis registered withmember.RegisterPermission; Owner and Finance roles receive it from a grants migration (the00313shape); a custom role gets it by hand andDEPLOY_NOTES.mdsays so. - A9 Ruling: The invoice export, if one exists, takes
from/toand defaults to the organization’s current financial year when both are omitted. If none exists, Task 5 addssales_invoice_registeras an export report with the same default. Task 5 step 1 decides which, by reading the code. - A10 Ruling:
payables.StatementFormirrors R16 oncontrol_kind = 'ap': lines ofkind = 'normal', the supplier’sparty_id, one currency (default the supplier’s),entry_datein[from, to], orderedentry_date, posted_seq, line_no;fx_differencelines excluded. The balance is credit-normal (a positive balance is money owed to the supplier). Opening = Σ(credit − debit) beforefrom. - 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_noand 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 andidas the tiebreak, and the list accepts a fixed set of sort columns. Task 7 verifies what the list does today before changing it.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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. |
Changed Go packages
Section titled “Changed Go packages”| 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.mdlisting 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.
Task 1 — Aging filters (AP and AR)
Section titled “Task 1 — Aging filters (AP and AR)”- 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, theallowQueryhelper. - 1.2 Failing tests (presence first). Seed three suppliers with open invoices of known amounts. Test: no filter returns all three (presence);
supplier_idreturns one;min_outstanding/max_outstandingreturn the expected subset; the totals equal the sum of the returned rows;min > maxis 422; a malformed amount is 422; asupplier_idbelonging to another organization returns nothing (RLS plus explicitorg_id). Same for AR withcustomer_id. - 1.3 Implement. Replace the
*uuid.UUIDargument withFilter{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. RecomputeSuppliers/Customersrollups andTotalsfrom the surviving rows. - 1.4 Handler and contract. Add the four parameters to
allowQuery, toapi/openapi.yaml, runmake 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_totalequals the sum;overdue_totalequals the sum minuscurrent. - 2.2 Implement.
extendedEdgestable,ExtendedBucketFor,ExtendedBucketAmountswithAdd.scheme=extendedselects it;standardis unchanged and its existing tests stay green untouched. - 2.3 Handler and contract.
schemeenum, response fieldsoutstanding_total,overdue_total. Update theap_agingexport to acceptscheme; its default keeps the old columns. - 2.4 Drift test. Every bucket in
Extendedappears 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.
Task 3 — AP grouped by due year
Section titled “Task 3 — AP grouped by due year”- 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_yearwithscheme=extendedis 422. - 3.2 Implement in
AgingAsOfas a second grouping over the same open-document set (one query, two projections), not a second query. - 3.3 Contract, regenerate, commit.
Task 4 — Invoice analysis (read)
Section titled “Task 4 — Invoice analysis (read)”- 4.1 Read. The sales document and receipt tables,
receivablesposting 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 (
rateisnull, 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 aggregateSELECTs, no rows loaded. The rate usesmoney.Rate. - 4.4 Route.
GET /reports/invoice-analysis?year=&month=; gated byreports.read,receivables.analysis.read. Validatemonth1–12 andyeara four-digit year; rejectmonthwithoutyear. - 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; write01081(the00313shape); write01080; add the migration range;make generate. - 5.3 Export.
invoice_analysis(paramsyear,month) and the invoice export (paramsfrom,to, default the current financial year when both are omitted). Add both toreports.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);
TestEveryExportableReportNamesItsOwnerReadKeystays green. - 5.5 Commit.
Task 6 — Supplier statement
Section titled “Task 6 — Supplier statement”- 6.1 Read.
internal/platform/receivables/statement.goand 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_differenceexcluded, a reversed invoice shown as two lines, an aging block as ofto(extended buckets),from > torefused. Tie-out drift test: for every supplier in the fixture, the closing balance of a statement from the beginning of time equalspayables.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, exportsupplier_statement(from,to,supplier_idrequired,currency), owner keypayables.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) andweb/src/features/receivableslist 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).
Task 8 — Web
Section titled “Task 8 — Web”- 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; nofloat. - 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;
Yearmode renders year columns; an empty result shows the empty state; the analysis view is absent without the permission. - 8.7 Commit.
Task 9 — Close-out
Section titled “Task 9 — Close-out”- One full
go test -p 2 -count=1 ./...and the web suite;make generateleavesgit statusclean. -
docs/ROADMAP.md: tick the entry (text inROADMAP-entry.mdbeside this plan). -
docs/DEPLOY_NOTES.md: re-grantreceivables.analysis.readto 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 onjournal_lineorap_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.
Security checklist (reviewed at Task 9)
Section titled “Security checklist (reviewed at Task 9)”- Every new route is behind
httpx.Protectedand inapi/openapi.yaml;api/public_routes.txtgains 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.exportplus 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.FilterandAgingFiltered(AR:receivables.AgingFiltered); the amount bound is applied as rows are read. - Task 2:
payables/aging_extended.go(ExtendedBucketFor,ExtendedOf,OutstandingTotal,OverdueTotal). Theap_agingexport also takessupplier_id,min_outstanding,max_outstanding,scheme;ar_agingtakescustomer_id,min_outstanding,max_outstanding. - Task 3:
ByDueYear, from the same rows as the buckets (one query, two projections). - Task 4:
receivables/analysis.go, routeGET /reports/invoice-analysis, permissionreceivables.analysis.read. - Task 5: the repository had no invoice export, so
sales_invoice_registerwas added; its range defaults to the fiscal year at request time and is stored explicitly on the export. Migrations01080(export CHECK) and01081(grants). - Task 6:
payables/statement.go, routeGET /payables/suppliers/{id}/statementand thesupplier_statementexport (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.