Debit Notes (AR and AP) 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: Add customer_debit_note (AR) and supplier_debit_note (AP) as first-class documents: issued, posted, allocatable, aged, reported, e-invoiced (MyInvois 03 / 13), and shown in the web app.
Architecture: Each is a new kind value in the existing sales_invoice / purchase_invoice table, exactly as credit notes are. It posts like an invoice (never mirrored), uses the invoice workflow graph shape, and is a valid target (never a source) of receipts, payments and credit-note offsets. One migration widens the CHECKs and the two allocation-guard triggers; Go, OpenAPI, and web follow the kind through every list that names the two existing kinds.
Tech Stack: Go + pgx + goose migrations, Postgres (testdb containers), oapi-codegen / sqlc / make generate, React + vitest + Playwright, i18n en/ms/zh.
Spec: docs/superpowers/specs/2026-10-05-debit-notes-design.md
Global Constraints
Section titled “Global Constraints”- Dependencies point downward only:
modules/→platform/→kernel/. Modules never write journal lines; the debit note posts throughaccounting/posting rules keyed(kind, semantic). - A float never touches money:
money.Amount,NUMERIC(19,4), a string on the wire. - Every business table keeps
org_id,ENABLE+FORCE ROW LEVEL SECURITY, policyorg_isolation. No new table is added by this plan. - Vocabulary values are strings: kinds are
customer_debit_noteandsupplier_debit_noteverbatim. - The ledger is append-only;
accounting/reacts to semantic tags, never state names. - Every user-facing name is a translation row / i18n key in en, ms and zh.
- The contract is
api/openapi.yaml; edit it, thenmake generate. Never hand-edit generated files. - A convention that is not tested drifts: watch each new guard fail once (break the thing, read the message, restore). A test that asserts something is absent must first show it present without the mechanism (AGENTS.md “Establish the presence before you assert the absence”).
- While iterating test only the packages you touched; run
go test -p 1 ./...once before the final merge. Database tests usetestdb.Start. - Use a unique
ERP_STACK/ERP_PG_PORTper agent (memory: agent-stack-isolation). At most two plans run at once. - Commit only when this plan’s step says so. The user’s working tree has unrelated uncommitted edits (
docs/ROADMAP.md,web/e2e/*,web/src/app/shell/*,web/src/lib/i18n/locales/*/shell.json,web/e2e/sample/): nevergit add -A; add the exact paths a task names, and never revert those files. - Commit trailer on every commit:
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>. - Reference prefixes: AR
DN, APSDN. There is no opening debit note: an old system’s open debit note is migrated as an opening invoice (same sign, same ledger effect); settled ones go to the archive under their own kind. - MyInvois types
03(debit note) and13(self-billed debit note) are not sandbox-validated; the roadmap carries that as an open item for the adviser.
Task order and dependencies
Section titled “Task order and dependencies”DB-1 → AR-1 → AR-2 → AR-3 → AR-4 → API-1 → AP-1..AP-11 → EX-1..EX-11 → WEB-1..WEB-10 → DOC-1. DB-1 must land first: every behavioural test below fails without the widened CHECKs and triggers.
Task 1: DB-1 — Migration 01070 — widen the kind CHECKs and the allocation guards
Section titled “Task 1: DB-1 — Migration 01070 — widen the kind CHECKs and the allocation guards”Files:
- Create:
db/migrations/01070_debit_notes.sql - Modify:
internal/kernel/db/migrationranges_test.go:~91(add the range row after{1040, 1049, ...}) - Create:
internal/platform/receivables/migration01070_test.go
Interfaces:
-
Produces:
sales_invoice.kindaccepts'customer_debit_note';purchase_invoice.kindaccepts'supplier_debit_note'; both allocation guards accept a debit note as theinvoice_idtarget and still refuse it ascredit_note_idsource;tax_return_line.source_kind,einvoice_submission.document_type(03,13) andarchived_document.kindaccept the new values. -
Step 1: Write the failing test —
internal/platform/receivables/migration01070_test.go
package receivables_test
// Migration 01070 widens five constraints and two guard functions. The// behavioural tests (AR-1.., AP-1..) prove the rules; this one proves the// migration itself, so a revert of the SQL alone is caught here by name.
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/receivables/receivablestest")
func TestMigration01070WidensTheKindConstraints(t *testing.T) { e := receivablestest.New(t) def := func(constraint string) string { var d string require.NoError(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1 LIMIT 1`, constraint).Scan(&d) }), constraint) return d } for constraint, want := range map[string]string{ "sales_invoice_kind_known": "customer_debit_note", "purchase_invoice_kind_known": "supplier_debit_note", "purchase_invoice_original_only_credit_note": "supplier_debit_note", "tax_return_line_source_kind_known": "customer_debit_note", "archived_document_kind_known": "supplier_debit_note", "einvoice_submission_type_known": "'13'", "einvoice_submission_type_matches_source": "'03'", } { require.Contains(t, def(constraint), want, constraint) } // The guards: each function body names the debit note as an allowed target. for fn, want := range map[string]string{"ar_allocation_guard": "customer_debit_note", "ap_allocation_guard": "supplier_debit_note"} { var src string require.NoError(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT prosrc FROM pg_proc WHERE proname = $1`, fn).Scan(&src) }), fn) require.Contains(t, src, want, fn) }}- Step 2: Run to verify it fails
Run: go test ./internal/platform/receivables/ -run TestMigration01070 -count=1
Expected: FAIL — sales_invoice_kind_known definition does not contain customer_debit_note.
- Step 3: Add the range row — in
internal/kernel/db/migrationranges_test.go, after the{1040, 1049, ...}row:
{1070, 1079, "Phase 7 debit notes (2026-10-05-debit-notes-design.md)"},- Step 4: Write the migration —
db/migrations/01070_debit_notes.sql
-- +goose Up-- Debit notes (2026-10-05-debit-notes-design.md): customer_debit_note and-- supplier_debit_note are new kinds in the existing tables. Everything below-- widens an existing check; no table is added, so RLS is untouched.
-- sales_invoice: the kind, and the credit-note shape. A debit note names an-- original invoice and a reason OPTIONALLY; an opening document still names no-- original (00640's relaxation is kept).ALTER TABLE sales_invoice DROP CONSTRAINT sales_invoice_kind_known;ALTER TABLE sales_invoice ADD CONSTRAINT sales_invoice_kind_known CHECK (kind IN ('sales_invoice','customer_credit_note','customer_debit_note'));ALTER TABLE sales_invoice DROP CONSTRAINT sales_invoice_credit_note_shape;ALTER TABLE sales_invoice ADD CONSTRAINT sales_invoice_credit_note_shape CHECK ( CASE WHEN is_opening THEN original_invoice_id IS NULL WHEN kind = 'customer_debit_note' THEN true ELSE (kind = 'customer_credit_note') = (original_invoice_id IS NOT NULL) END AND (kind = 'customer_debit_note' OR (kind = 'customer_credit_note') = (reason IS NOT NULL)));
-- purchase_invoice: the kind, and the optional original (a credit note's, or a-- debit note's). 00381's self-billed credit-note rule is untouched.ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_kind_known;ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_kind_known CHECK (kind IN ('purchase_invoice','supplier_credit_note','supplier_debit_note'));ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_original_only_credit_note;ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_original_only_credit_note CHECK ( original_invoice_id IS NULL OR kind IN ('supplier_credit_note','supplier_debit_note'));
ALTER TABLE tax_return_line DROP CONSTRAINT tax_return_line_source_kind_known;ALTER TABLE tax_return_line ADD CONSTRAINT tax_return_line_source_kind_known CHECK (source_kind IN ('sales_invoice','customer_credit_note','customer_debit_note','adjustment'));
ALTER TABLE archived_document DROP CONSTRAINT archived_document_kind_known;ALTER TABLE archived_document ADD CONSTRAINT archived_document_kind_known CHECK (kind IN ( 'sales_invoice', 'customer_credit_note', 'customer_debit_note', 'customer_receipt', 'purchase_invoice', 'supplier_credit_note', 'supplier_debit_note', 'supplier_payment'));
-- MyInvois types 03 (debit note) and 13 (self-billed debit note).ALTER TABLE einvoice_submission DROP CONSTRAINT einvoice_submission_type_known;ALTER TABLE einvoice_submission ADD CONSTRAINT einvoice_submission_type_known CHECK (document_type IN ('01','02','03','11','12','13'));ALTER TABLE einvoice_submission DROP CONSTRAINT einvoice_submission_type_matches_source;ALTER TABLE einvoice_submission ADD CONSTRAINT einvoice_submission_type_matches_source CHECK ( (sales_document_id IS NOT NULL) = (document_type IN ('01','02','03')));
-- The allocation guards: the target of an allocation is an invoice OR a debit-- note. The function bodies are 00312's and 00304's verbatim, with only the-- target test changed; the source test (credit_note_id) is deliberately NOT-- widened: a debit note is never an allocation source.-- +goose StatementBeginCREATE OR REPLACE FUNCTION ar_allocation_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_inv public.sales_invoice%ROWTYPE; v_cn public.sales_invoice%ROWTYPE; v_rct public.customer_receipt%ROWTYPE; v_orig public.ar_allocation%ROWTYPE; v_out NUMERIC; v_out_base NUMERIC; v_left NUMERIC;BEGIN SELECT * INTO v_inv FROM public.sales_invoice WHERE org_id = NEW.org_id AND id = NEW.invoice_id FOR UPDATE; IF v_inv.kind NOT IN ('sales_invoice', 'customer_debit_note') THEN RAISE EXCEPTION 'allocation target % is not a sales invoice or debit note', NEW.invoice_id USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_target_is_invoice'; END IF; IF NEW.currency <> v_inv.currency THEN RAISE EXCEPTION 'allocation currency % is not the invoice''s %', NEW.currency, v_inv.currency USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_currency_matches'; END IF;
IF NEW.reverses_id IS NOT NULL THEN SELECT * INTO v_orig FROM public.ar_allocation WHERE org_id = NEW.org_id AND id = NEW.reverses_id; IF v_orig.reverses_id IS NOT NULL OR v_orig.invoice_id <> NEW.invoice_id OR v_orig.receipt_id IS DISTINCT FROM NEW.receipt_id OR v_orig.credit_note_id IS DISTINCT FROM NEW.credit_note_id OR NEW.amount <> -v_orig.amount OR NEW.base_amount <> -v_orig.base_amount THEN RAISE EXCEPTION 'allocation % must mirror the allocation it reverses', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_reversal_mirrors'; END IF; RETURN NEW; END IF;
IF NOT (SELECT i.accrual_open FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = v_inv.instance_id) THEN RAISE EXCEPTION 'invoice % is not issued', coalesce(v_inv.reference, v_inv.id::text) USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_invoice_posted'; END IF;
IF NEW.credit_note_id IS NOT NULL THEN SELECT * INTO v_cn FROM public.sales_invoice WHERE org_id = NEW.org_id AND id = NEW.credit_note_id FOR UPDATE; IF v_cn.kind <> 'customer_credit_note' OR NOT (SELECT i.accrual_open FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = v_cn.instance_id) THEN RAISE EXCEPTION 'credit note % is not an issued credit note', NEW.credit_note_id USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_credit_note_posted'; END IF; IF v_cn.customer_party_id <> v_inv.customer_party_id THEN RAISE EXCEPTION 'credit note and invoice belong to different customers' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_same_customer'; END IF; IF v_cn.currency <> v_inv.currency OR v_cn.fx_rate <> v_inv.fx_rate THEN -- D6 / AP Ruling P23 RAISE EXCEPTION 'credit note and invoice carry different currencies or rates' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_rate_matches'; END IF; SELECT v_cn.gross_amount - coalesce(sum(a.amount), 0) - NEW.amount INTO v_left FROM public.ar_allocation a WHERE a.org_id = NEW.org_id AND a.credit_note_id = NEW.credit_note_id; IF v_left < 0 THEN RAISE EXCEPTION 'credit note % has less left than %', v_cn.reference, NEW.amount USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_credit_not_over'; END IF; ELSE SELECT * INTO v_rct FROM public.customer_receipt WHERE org_id = NEW.org_id AND id = NEW.receipt_id FOR UPDATE; IF v_rct.customer_party_id <> v_inv.customer_party_id THEN RAISE EXCEPTION 'receipt and invoice belong to different customers' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_same_customer'; END IF; IF v_rct.currency <> v_inv.currency THEN RAISE EXCEPTION 'receipt and invoice are in different currencies' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_currency_matches'; END IF; SELECT v_rct.amount - coalesce(sum(a.amount), 0) - NEW.amount INTO v_left FROM public.ar_allocation a WHERE a.org_id = NEW.org_id AND a.receipt_id = NEW.receipt_id; IF v_left < 0 THEN RAISE EXCEPTION 'receipt % has less left than %', coalesce(v_rct.reference, v_rct.id::text), NEW.amount USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_receipt_not_over'; END IF; END IF;
SELECT v_inv.gross_amount - coalesce(sum(a.amount), 0) - NEW.amount, v_inv.gross_base - coalesce(sum(a.base_amount), 0) - NEW.base_amount INTO v_out, v_out_base FROM public.ar_allocation a WHERE a.org_id = NEW.org_id AND a.invoice_id = NEW.invoice_id; IF v_out < 0 THEN RAISE EXCEPTION 'invoice % would be over-allocated', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_not_over'; END IF; IF v_out = 0 AND v_out_base <> 0 THEN RAISE EXCEPTION 'an allocation that clears invoice % must clear its base', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_clears_base'; END IF; RETURN NEW;END $$;-- +goose StatementEnd
-- +goose StatementBeginCREATE OR REPLACE FUNCTION ap_allocation_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_inv public.purchase_invoice%ROWTYPE; v_cn public.purchase_invoice%ROWTYPE; v_pay public.supplier_payment%ROWTYPE; v_orig public.ap_allocation%ROWTYPE; v_out NUMERIC; v_out_base NUMERIC; v_left NUMERIC;BEGIN SELECT * INTO v_inv FROM public.purchase_invoice WHERE org_id = NEW.org_id AND id = NEW.invoice_id FOR UPDATE; IF v_inv.kind NOT IN ('purchase_invoice', 'supplier_debit_note') THEN RAISE EXCEPTION 'allocation target % is not a purchase invoice or debit note', NEW.invoice_id USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_target_is_invoice'; END IF; IF NEW.currency <> v_inv.currency THEN RAISE EXCEPTION 'allocation currency % is not the invoice''s %', NEW.currency, v_inv.currency USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_currency_matches'; END IF;
IF NEW.reverses_id IS NOT NULL THEN SELECT * INTO v_orig FROM public.ap_allocation WHERE org_id = NEW.org_id AND id = NEW.reverses_id; IF v_orig.reverses_id IS NOT NULL OR v_orig.invoice_id <> NEW.invoice_id OR v_orig.payment_id IS DISTINCT FROM NEW.payment_id OR v_orig.credit_note_id IS DISTINCT FROM NEW.credit_note_id OR NEW.amount <> -v_orig.amount OR NEW.base_amount <> -v_orig.base_amount THEN RAISE EXCEPTION 'allocation % must mirror the allocation it reverses', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_reversal_mirrors'; END IF; RETURN NEW; END IF;
IF NOT (SELECT i.accrual_open FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = v_inv.instance_id) THEN RAISE EXCEPTION 'invoice % is not posted', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_invoice_posted'; END IF;
IF NEW.credit_note_id IS NOT NULL THEN SELECT * INTO v_cn FROM public.purchase_invoice WHERE org_id = NEW.org_id AND id = NEW.credit_note_id FOR UPDATE; IF v_cn.kind <> 'supplier_credit_note' OR NOT (SELECT i.accrual_open FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = v_cn.instance_id) THEN RAISE EXCEPTION 'credit note % is not a posted credit note', NEW.credit_note_id USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_credit_note_posted'; END IF; IF v_cn.supplier_party_id <> v_inv.supplier_party_id THEN RAISE EXCEPTION 'credit note and invoice belong to different suppliers' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_same_supplier'; END IF; IF v_cn.currency <> v_inv.currency THEN RAISE EXCEPTION 'credit note and invoice are in different currencies' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_currency_matches'; END IF; IF v_cn.fx_rate <> v_inv.fx_rate THEN -- Ruling P23 RAISE EXCEPTION 'credit note and invoice carry different rates' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_rate_matches'; END IF; SELECT v_cn.gross_amount - coalesce(sum(a.amount), 0) - NEW.amount INTO v_left FROM public.ap_allocation a WHERE a.org_id = NEW.org_id AND a.credit_note_id = NEW.credit_note_id; IF v_left < 0 THEN RAISE EXCEPTION 'credit note % has less left than %', v_cn.reference, NEW.amount USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_credit_not_over'; END IF; ELSE SELECT * INTO v_pay FROM public.supplier_payment WHERE org_id = NEW.org_id AND id = NEW.payment_id; IF v_pay.supplier_party_id <> v_inv.supplier_party_id THEN RAISE EXCEPTION 'payment and invoice belong to different suppliers' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_same_supplier'; END IF; IF v_pay.currency <> v_inv.currency THEN RAISE EXCEPTION 'payment and invoice are in different currencies' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_currency_matches'; END IF; END IF;
SELECT v_inv.gross_amount - coalesce(sum(a.amount), 0) - NEW.amount, v_inv.gross_base - coalesce(sum(a.base_amount), 0) - NEW.base_amount INTO v_out, v_out_base FROM public.ap_allocation a WHERE a.org_id = NEW.org_id AND a.invoice_id = NEW.invoice_id; IF v_out < 0 THEN RAISE EXCEPTION 'invoice % would be over-allocated', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_not_over'; END IF; IF v_out = 0 AND v_out_base <> 0 THEN RAISE EXCEPTION 'an allocation that clears invoice % must clear its base', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_clears_base'; END IF; RETURN NEW;END $$;-- +goose StatementEnd
-- +goose Down-- Refuses to run while a debit note exists: narrowing the CHECKs would fail-- anyway, and silently deleting documents is not a rollback.-- +goose StatementBeginDO $$ BEGIN IF EXISTS (SELECT 1 FROM public.sales_invoice WHERE kind = 'customer_debit_note') OR EXISTS (SELECT 1 FROM public.purchase_invoice WHERE kind = 'supplier_debit_note') THEN RAISE EXCEPTION 'debit notes exist; void and remove them before migrating down'; END IF;END $$;-- +goose StatementEndALTER TABLE einvoice_submission DROP CONSTRAINT einvoice_submission_type_matches_source;ALTER TABLE einvoice_submission ADD CONSTRAINT einvoice_submission_type_matches_source CHECK ( (sales_document_id IS NOT NULL) = (document_type IN ('01','02')));ALTER TABLE einvoice_submission DROP CONSTRAINT einvoice_submission_type_known;ALTER TABLE einvoice_submission ADD CONSTRAINT einvoice_submission_type_known CHECK (document_type IN ('01','02','11','12'));ALTER TABLE archived_document DROP CONSTRAINT archived_document_kind_known;ALTER TABLE archived_document ADD CONSTRAINT archived_document_kind_known CHECK (kind IN ( 'sales_invoice', 'customer_credit_note', 'customer_receipt', 'purchase_invoice', 'supplier_credit_note', 'supplier_payment'));ALTER TABLE tax_return_line DROP CONSTRAINT tax_return_line_source_kind_known;ALTER TABLE tax_return_line ADD CONSTRAINT tax_return_line_source_kind_known CHECK (source_kind IN ('sales_invoice','customer_credit_note','adjustment'));ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_original_only_credit_note;ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_original_only_credit_note CHECK ( original_invoice_id IS NULL OR kind = 'supplier_credit_note');ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_kind_known;ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_kind_known CHECK (kind IN ('purchase_invoice','supplier_credit_note'));ALTER TABLE sales_invoice DROP CONSTRAINT sales_invoice_credit_note_shape;ALTER TABLE sales_invoice ADD CONSTRAINT sales_invoice_credit_note_shape CHECK ( CASE WHEN is_opening THEN original_invoice_id IS NULL ELSE (kind = 'customer_credit_note') = (original_invoice_id IS NOT NULL) END AND (kind = 'customer_credit_note') = (reason IS NOT NULL));ALTER TABLE sales_invoice DROP CONSTRAINT sales_invoice_kind_known;ALTER TABLE sales_invoice ADD CONSTRAINT sales_invoice_kind_known CHECK (kind IN ('sales_invoice','customer_credit_note'));-- The guard functions are left widened on Down: with no debit note in the-- tables the extra IN-list entry is inert, and re-creating 00312's/00304's-- bodies here would duplicate them for nothing.- Step 5: Run to verify it passes
Run: go test ./internal/platform/receivables/ -run TestMigration01070 -count=1 && go test ./internal/kernel/db/ -run 'TestEveryMigrationIsNumberedInItsOwnersRange|TestConstraintNames' -count=1
Expected: PASS. Then run the migration down/up test the repo has for every migration (find it with grep -rn "StartAt" internal --include=*_test.go -l) and expect PASS; if a down-migration test lists migrations it names 01070, add nothing: the Down block above is its contract.
-
Step 6: Watch it fail once — temporarily change
'customer_debit_note'to'customer_debit_notx'in the SQL, re-run Step 5’s first command, read the failing constraint name, then restore. -
Step 7: Commit
git add db/migrations/01070_debit_notes.sql internal/kernel/db/migrationranges_test.go internal/platform/receivables/migration01070_test.gogit commit -m "Debit notes: migration 01070 widens kind CHECKs and allocation guards" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 2: AR-1 — Customer debit note — kind, graph, create, issue, post
Section titled “Task 2: AR-1 — Customer debit note — kind, graph, create, issue, post”Files:
- Modify:
internal/platform/receivables/doc.go:16-30(constant,SourceKinds, newsettlable) - Modify:
internal/platform/receivables/graphs.go:10-17(register), appendDebitNoteGraph - Modify:
internal/platform/receivables/document.go:379-394(resolveKind) - Modify:
internal/platform/receivables/transition.go:70(prefix),:270(due date) - Modify:
internal/platform/receivables/posting.go:29,:67 - Modify:
internal/platform/workflow/model.go:58(AccrualOnlyKinds) - Modify:
internal/platform/receivables/receivablestest/env.go(addDebitNote) - Test:
internal/platform/receivables/debitnote_test.go(create)
Interfaces:
-
Produces:
receivables.KindDebitNote = "customer_debit_note";receivables.DebitNoteGraph(ownerSeat uuid.UUID) workflow.Graph;receivables.settlable(kind string) bool(package-private);(*receivablestest.Env).DebitNote(t, c receivables.Customer, original *receivables.Document, lines ...receivables.LineInput) receivables.Document. -
Consumes: DB-1 (kind accepted by the CHECK).
-
Step 1: Write the failing tests —
internal/platform/receivables/debitnote_test.go
package receivables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/receivables/receivablestest")
func svt8(e *receivablestest.Env, t *testing.T, price string) receivables.LineInput { return receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "SVT8"), "1", price)}
// A debit note posts exactly as an invoice: Dr receivable, Cr income and tax.// Contrast: the credit note posts the mirror (posting_test.go).func TestADebitNotePostsLikeAnInvoice(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, svt8(e, t, "100.00"))) dn := e.Issue(t, e.DebitNote(t, c, &inv, svt8(e, t, "50.00"))) require.Equal(t, "issued", dn.State) require.Contains(t, *dn.Reference, "DN") byCode := e.LinesByAccountCode(t, e.EntryFor(t, dn.InstanceID, "accrual")) require.Equal(t, "54.0000", byCode["1540"].Debit.String()) require.Equal(t, "50.0000", byCode["4100"].Credit.String()) require.Equal(t, "4.0000", byCode["2140"].Credit.String())}
// The original is optional; when named it must be an issued invoice of the// same customer (presence: the same-customer original is accepted above).func TestADebitNoteMayStandAloneButAnOriginalMustBeAnIssuedInvoiceOfTheCustomer(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c, other := e.Customer(t, "KEDAI"), e.Customer(t, "LAIN") standalone := e.DebitNote(t, c, nil, svt8(e, t, "10.00")) require.Nil(t, standalone.OriginalInvoiceID) issued := e.Issue(t, e.Invoice(t, c, svt8(e, t, "100.00"))) draft := e.Invoice(t, c, svt8(e, t, "100.00")) for name, orig := range map[string]*receivables.Document{"another customer's": &issued, "an unissued invoice": &draft} { customer := other if name == "an unissued invoice" { customer = c } err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{ Kind: receivables.KindDebitNote, CustomerID: customer.PartyID, OriginalInvoiceID: &orig.ID, Currency: customer.Currency, Lines: []receivables.LineInput{svt8(e, t, "10.00")}}) return err }) var fe *receivables.FieldError require.ErrorAs(t, err, &fe, name) require.Equal(t, "original_invoice", fe.Rule, name) } // The credit note still demands its original (the shape is not loosened for it). err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { reason := "x" _, err := receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{ Kind: receivables.KindCreditNote, CustomerID: c.PartyID, Reason: &reason, Currency: c.Currency, Lines: []receivables.LineInput{svt8(e, t, "10.00")}}) return err }) var fe *receivables.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "required", fe.Rule)}
// A debit note takes the customer's payment terms like an invoice; a credit// note is due at once. And it is never offset on issue (a credit note is).func TestADebitNoteIsDueOnTermsAndIsNotSelfAllocated(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.CustomerWithTerms(t, "KEDAI", 30) inv := e.Issue(t, e.Invoice(t, c, svt8(e, t, "100.00"))) cn := e.IssueCreditNote(t, e.CreditNote(t, inv, "Returned goods", svt8(e, t, "10.00"))) require.Len(t, e.AllocationsOf(t, cn.ID), 1, "PRESENCE: a credit note offsets its original on issue") require.Equal(t, cn.InvoiceDate.UTC(), cn.DueOn.UTC(), "a credit note is due at once") dn := e.Issue(t, e.DebitNote(t, c, &inv, svt8(e, t, "50.00"))) require.Empty(t, e.AllocationsOf(t, dn.ID)) require.Equal(t, dn.InvoiceDate.AddDate(0, 0, 30).UTC(), dn.DueOn.UTC()) out, _ := e.Outstanding(t, dn) require.Equal(t, dn.GrossAmount.String(), out.String())}- Step 2: Run to verify it fails
Run: go test ./internal/platform/receivables/ -run 'TestADebitNote' -count=1
Expected: FAIL — compile error undefined: receivables.KindDebitNote / e.DebitNote undefined.
- Step 3: Implement
doc.go — constant, source kinds, helper:
const ( KindInvoice = "sales_invoice" KindCreditNote = "customer_credit_note" KindDebitNote = "customer_debit_note" KindReceipt = "customer_receipt"var SourceKinds = []string{KindInvoice, KindCreditNote, KindDebitNote, KindReceipt}
// settlable is true for the documents a receipt or a credit note settles:// an invoice, or a debit note (which is an invoice for what the customer owes).func settlable(kind string) bool { return kind == KindInvoice || kind == KindDebitNote }graphs.go — in init():
workflow.RegisterSeededGraph(KindDebitNote, DebitNoteGraph) workflow.AllowSoleOwnerException(KindDebitNote, false)and append:
// DebitNoteGraph is InvoiceGraph under its own kind: its preparer issues it,// and its only exit from issued is the owner-granted, reversing void. A debit// note raises what the customer owes; it is not an approval-gated reduction// like a credit note, so no second person is required.func DebitNoteGraph(ownerSeat uuid.UUID) workflow.Graph { g := InvoiceGraph(ownerSeat) g.Kind = KindDebitNote for i := range g.Edges { if g.Edges[i].ActionKey == "issue" { g.Edges[i].Labels = ls("Issue debit note", "开具借项通知单", "Keluarkan nota debit") } } return g.Normalized()}workflow/model.go:58 — add "customer_debit_note" to AccrualOnlyKinds (and "supplier_debit_note"; AP-1 relies on it):
var AccrualOnlyKinds = []string{"purchase_invoice", "supplier_credit_note", "supplier_debit_note", "sales_invoice", "customer_credit_note", "customer_debit_note", "payroll_run"}document.go resolveKind — add a case before default, and widen the default message:
case KindDebitNote: if in.OriginalInvoiceID != nil { original, err := GetDocument(ctx, tx, *in.OriginalInvoiceID) switch { case errors.Is(err, ErrNotFound), err == nil && (original.Kind != KindInvoice || original.IssuedAt == nil || original.CustomerID != in.CustomerID): return fieldErr("original_invoice_id", "original_invoice", "a debit note adds to an issued invoice of the same customer") case err != nil: return err case original.CancelledAt != nil: return ErrOriginalVoided } } if in.Reason != nil { r := strings.TrimSpace(*in.Reason) switch { case r == "": in.Reason = nil case len(r) > 500 || recordtext.Check("reason", r) != nil: return fieldErr("reason", "reason_shape", "the reason is at most 500 characters, in English or Bahasa Malaysia") default: in.Reason = &r } } return nil default: return fieldErr("kind", "kind_known", "a sales invoice, a customer credit note or a customer debit note")(a debit note keeps its own currency and rate: nothing is overwritten.)
transition.go:70 — var prefixes = map[string]string{KindInvoice: "INV", KindCreditNote: "CRN", KindDebitNote: "DN", KindReceipt: "RCT"}; :270 — if d.Kind == KindInvoice || d.Kind == KindDebitNote {.
posting.go:29 — return []accounting.PostingRule{AccrualRule{KindName: KindInvoice}, AccrualRule{KindName: KindCreditNote}, AccrualRule{KindName: KindDebitNote}, ReceiptRule{}}; :67 — add KindDebitNote: "Customer debit note " to the label map. mirror stays d.Kind == KindCreditNote (a debit note is not mirrored).
receivablestest/env.go (after CreditNote):
// DebitNote files a draft customer debit note in the customer's currency,// optionally against an issued invoice (original may be nil).func (e *Env) DebitNote(t *testing.T, c receivables.Customer, original *receivables.Document, lines ...receivables.LineInput) receivables.Document { t.Helper() in := receivables.DocumentInput{Kind: receivables.KindDebitNote, CustomerID: c.PartyID, Currency: c.Currency, Lines: lines} if original != nil { in.OriginalInvoiceID = &original.ID } var d receivables.Document require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, in) return err })) return d}- Step 4: Run to verify it passes
Run: go test ./internal/platform/receivables/... ./internal/platform/workflow/... -count=1 -p 1
Expected: PASS. Then go test ./internal/app/... ./cmd/worker/... -count=1 -p 1: tests that list every seeded kind or every kind with semantics (cmd/worker/wiring_test.go:160,185,209, internal/app seeded-kind drift tests) may fail naming customer_debit_note; add the new kind to each list the failing message names, then re-run.
-
Step 5: Watch it fail once — remove
AccrualRule{KindName: KindDebitNote}fromRules(), re-runTestADebitNotePostsLikeAnInvoice, read the “no posting rule” failure, restore. -
Step 6: Commit
git add internal/platform/receivables internal/platform/workflow/model.go cmd/worker/wiring_test.go internal/appgit commit -m "AR: customer debit note kind, graph, issue and posting" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"(If Step 4 changed no file under cmd/worker or internal/app, drop those paths from the git add.)
Task 3: AR-2 — A debit note is an allocation and receipt target, never a source
Section titled “Task 3: AR-2 — A debit note is an allocation and receipt target, never a source”Files:
- Modify:
internal/platform/receivables/allocation.go:288 - Modify:
internal/platform/receivables/receipt.go:244 - Test:
internal/platform/receivables/debitnote_allocation_test.go(create)
Interfaces:
-
Consumes:
settlable,KindDebitNote,Env.DebitNote(AR-1). -
Produces:
AllocateandcheckReceiptaccept a debit note as the invoice side. -
Step 1: Write the failing tests —
debitnote_allocation_test.go
package receivables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/receivables/receivablestest")
func TestAReceiptPaysADebitNote(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") dn := e.Issue(t, e.DebitNote(t, c, nil, svt8(e, t, "100.00"))) require.Len(t, e.AgingNow(t).Rows, 1) // PRESENCE: it is owed e.Record(t, e.Receipt(t, c, "108.00", nil, receivables.ReceiptLine{InvoiceID: dn.ID, Amount: money.MustParse("108.00")})) out, _ := e.Outstanding(t, e.Get(t, dn.ID)) require.Equal(t, "0.0000", out.String()) require.Empty(t, e.AgingNow(t).Rows)}
// A credit note's remainder may offset a debit note; the debit note itself is// never an allocation source (presence: the credit note is accepted as one).func TestACreditNoteOffsetsADebitNoteButNotTheOtherWayRound(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) e.Record(t, e.Receipt(t, c, "100.00", nil, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")})) cn := e.IssueCreditNote(t, e.CreditNote(t, inv, "Returned goods", e.StdLine(t, "1", "30.00"))) // paid invoice: 30 left on the credit note dn := e.Issue(t, e.DebitNote(t, c, nil, e.StdLine(t, "1", "50.00")))
e.Allocate(t, receivables.AllocationInput{CreditNoteID: &cn.ID, InvoiceID: dn.ID, Amount: money.MustParse("30.00")}) out, _ := e.Outstanding(t, e.Get(t, dn.ID)) require.Equal(t, "20.0000", out.String())
err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.Allocate(ctx, tx, e.Clock, receivables.AllocationInput{CreditNoteID: &dn.ID, InvoiceID: inv.ID, Amount: money.MustParse("1.00")}, e.Staff) return err }) require.ErrorIs(t, err, receivables.ErrNotAllocatable)}
// The trigger is the backstop: the same refusal, by constraint name, with Go// bypassed (presence: the accepted allocation above used the same trigger).func TestTheGuardRefusesADebitNoteAsAllocationSource(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) dn := e.Issue(t, e.DebitNote(t, c, nil, e.StdLine(t, "1", "50.00"))) _, err := rawAllocate(e, inv.ID, nil, &dn.ID, "MYR", "1.00", "1.00", nil) requirePgError(t, err, "23514", "ar_allocation_credit_note_posted")}
func TestADebitNoteWithAllocationsCannotBeVoided(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") free := e.Issue(t, e.DebitNote(t, c, nil, e.StdLine(t, "1", "10.00"))) paid := e.Issue(t, e.DebitNote(t, c, nil, e.StdLine(t, "1", "100.00"))) e.Record(t, e.Receipt(t, c, "40.00", nil, receivables.ReceiptLine{InvoiceID: paid.ID, Amount: money.MustParse("40.00")})) require.NoError(t, voidDoc(e, free), "PRESENCE: an unallocated debit note voids") require.ErrorIs(t, voidDoc(e, paid), receivables.ErrHasAllocations)}(voidDoc and ErrHasAllocations exist: void_test.go:15, transition.go void guard.)
- Step 2: Run to verify it fails
Run: go test ./internal/platform/receivables/ -run 'TestAReceiptPaysADebitNote|TestACreditNoteOffsets|TestTheGuardRefuses|TestADebitNoteWithAllocations' -count=1
Expected: FAIL — receipt does not allocate to a debit note (ErrInvoiceNotReceivable) and ErrNotAllocatable on the credit-note offset.
- Step 3: Implement
allocation.go:288 — case inv.Kind != KindInvoice || ... becomes case !settlable(inv.Kind) || !inv.AccrualOpen, inv.CustomerID != customer, inv.Currency != currency:.
receipt.go:244 — inv.Kind != KindInvoice becomes !settlable(inv.Kind).
InvoiceOutstanding needs no change (a debit note takes the invoice_id branch).
- Step 4: Run to verify it passes
Run: go test ./internal/platform/receivables/... -count=1 -p 1
Expected: PASS.
-
Step 5: Watch it fail once — revert the
settlableuse inallocation.go, runTestACreditNoteOffsetsADebitNoteButNotTheOtherWayRound, readErrNotAllocatable, restore. -
Step 6: Commit
git add internal/platform/receivables/allocation.go internal/platform/receivables/receipt.go internal/platform/receivables/debitnote_allocation_test.gogit commit -m "AR: a debit note is an allocation and receipt target" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 4: AR-3 — AR aging counts a debit note as owed
Section titled “Task 4: AR-3 — AR aging counts a debit note as owed”Files:
- Modify:
internal/platform/receivables/aging.go:81-82(appliedjoin) and:93-94(signCASE) - Test:
internal/platform/receivables/aging_test.go(append) - Check:
internal/platform/receivables/subledger_test.go(the sub-ledger = control drift test must still pass)
Interfaces: consumes AR-1/AR-2.
- Step 1: Write the failing test — append to
aging_test.go
// A debit note is owed, so it ages as a positive row. The sign CASE once read// "not an invoice, therefore a credit"; this pins the new kind to the invoice side.func TestADebitNoteAgesAsAPositiveRowAndLeavesWhenPaid(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") dn := e.Issue(t, e.DebitNote(t, c, nil, e.StdLine(t, "1", "100.00"))) a := e.AgingNow(t) require.Len(t, a.Rows, 1) require.Equal(t, receivables.KindDebitNote, a.Rows[0].Kind) require.Equal(t, "100.0000", a.Rows[0].Outstanding.String()) require.Equal(t, "100.0000", a.Rows[0].OutstandingBase.String()) e.Record(t, e.Receipt(t, c, "100.00", nil, receivables.ReceiptLine{InvoiceID: dn.ID, Amount: money.MustParse("100.00")})) require.Empty(t, e.AgingNow(t).Rows)}- Step 2: Run to verify it fails
Run: go test ./internal/platform/receivables/ -run TestADebitNoteAges -count=1
Expected: FAIL — Outstanding is -100.0000 (the ELSE -left_amt branch), or the paid note stays in aging (the applied join misses it).
- Step 3: Implement — in
agingSQL:
AND ((p.kind IN ('sales_invoice','customer_debit_note') AND a.invoice_id = p.id) OR (p.kind = 'customer_credit_note' AND a.credit_note_id = p.id) OR (p.kind = 'customer_receipt' AND a.receipt_id = p.id)) CASE WHEN o.kind IN ('sales_invoice','customer_debit_note') THEN o.left_amt ELSE -o.left_amt END, CASE WHEN o.kind IN ('sales_invoice','customer_debit_note') THEN o.left_base ELSE -o.left_base END- Step 4: Run to verify it passes
Run: go test ./internal/platform/receivables/... -count=1 -p 1 (includes the sub-ledger = control drift test and statement tests).
Expected: PASS.
-
Step 5: Watch it fail once — revert only the
CASElines, re-run, read the negative amount, restore. -
Step 6: Commit
git add internal/platform/receivables/aging.go internal/platform/receivables/aging_test.gogit commit -m "AR aging: a debit note ages as owed, not as a credit" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 5: AR-4 — Pin the AR edges — no opening debit note, no delivery link
Section titled “Task 5: AR-4 — Pin the AR edges — no opening debit note, no delivery link”Files:
- Modify:
internal/platform/receivables/deliverymatch.go:55(message only) - Test:
internal/platform/receivables/debitnote_edges_test.go(create)
Interfaces: consumes AR-1. No production behaviour change beyond one message: CreateOpeningDocument already refuses an unknown kind (opening.go:63-64) and deliveryKind has no debit-note entry on purpose, so the lookup yields "" and refuses every trade line.
- Step 1: Write the tests —
debitnote_edges_test.go
package receivables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/receivables/receivablestest" "github.com/mcom/erp/internal/platform/trade/chaintest")
// Decision (spec): a debit note brought forward is an opening invoice; there is no OB-DN.func TestAnOpeningDebitNoteIsNotAnOpeningKind(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") open := func(kind string) error { return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateOpeningDocument(ctx, tx, e.Clock, e.Staff, receivables.OpeningInput{ Kind: kind, CustomerID: c.PartyID, LegacyReference: "OLD-" + kind, DocumentDate: e.Clock.Now().AddDate(0, -1, 0), Outstanding: money.MustParse("75.00"), PostOn: e.Clock.Now()}) return err }) } require.NoError(t, open(receivables.KindInvoice), "PRESENCE: the same input opens as an invoice") var fe *receivables.FieldError require.ErrorAs(t, open(receivables.KindDebitNote), &fe) require.Equal(t, "kind_known", fe.Rule)}
// A debit note cannot be matched to a delivery line in v1. Presence is// deliverymatch_test.go, where the same line on an invoice is accepted.func TestADebitNoteLineCannotNameADeliveryLine(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") e.Stocked(t, bolt, "10", "3.00") cust := e.CustomerParty(t) so := e.SalesOrder(t, cust, chaintest.ItemLine(bolt, e.Unit, "5", "9.00")) do := e.Deliver(t, so, chaintest.Take(so.Lines[0].ID, "5")) e.ARCustomer(t, cust)
err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{Kind: receivables.KindDebitNote, CustomerID: cust, Currency: "MYR", Lines: []receivables.LineInput{chaintest.SalesLine(e.AR.StdLine(t, "1", "9.00"), do.Lines[0].ID)}}) return err }) var fe *receivables.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "trade_line_kind", fe.Rule)}(receivables.OpeningInput.Kind for an invoice with no LegacyReference clash is taken from opening_test.go’s own fixture; if CreateOpeningDocument needs a cut-over/fiscal fixture the presence call lacks, copy it from there.)
-
Step 2: Run —
go test ./internal/platform/receivables/ -run 'TestAnOpeningDebitNote|TestADebitNoteLineCannot' -count=1Expected: PASS (these pin behaviour that already holds, so they cannot fail on their own). -
Step 3: Watch each fail once — (a) temporarily add
KindDebitNoteto the kind test atopening.go:63and re-runTestAnOpeningDebitNote…: it fails; revert withgit checkout internal/platform/receivables/opening.go. (b) temporarily addKindDebitNote: "delivery_order"todeliveryKind: the second test fails; revert. -
Step 4: Message —
deliverymatch.go:55:"an invoice bills a delivery line; a credit note, a sales return line; a debit note names none". Rungo test ./internal/platform/receivables/... -count=1 -p 1→ PASS. -
Step 5: Commit
git add internal/platform/receivables/deliverymatch.go internal/platform/receivables/debitnote_edges_test.gogit commit -m "AR: pin that a debit note has no opening kind and no delivery link" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 6: API-1 — OpenAPI enums, regenerate, public spec
Section titled “Task 6: API-1 — OpenAPI enums, regenerate, public spec”Files:
- Modify:
api/openapi.yamlat the enum sites listed below - Regenerate (never hand-edit):
internal/gen/api/api.gen.go,web/src/lib/api/schema.d.ts,api/public-openapi.yaml - Test:
internal/gen/drift_test.go,web/src/drift/schema.test.ts,tools/publicapi/publicapi_test.go,internal/app/routes_test.go,internal/platform/webhooks/contract_test.go
Interfaces:
-
Produces: generated Go and TS types where
SalesDocumentKindcontainscustomer_debit_note,PurchaseInvoice.kind/PurchaseInvoiceInput.kindcontainsupplier_debit_note,EInvoiceSubmission.document_typecontains03and13. Later tasks (AP, EX, WEB) compile against these. -
Step 1: Edit the spec. In
api/openapi.yaml, add the value (and fix the “invoice or credit note” description text beside it) at:SalesDocumentKind(~:21322);ArAgingRow.kind(~:20373) andApAgingRow.kind(~:20290);PurchaseInvoiceInput.kind(~:20435) andPurchaseInvoice.kind(~:20467);EInvoiceSubmission.source_kind(~:21259) and.document_type(~:21261, add"03","13");TaxReturnLine.source_kind(~:21720);TradeChainInvoice.kind(~:21959);ArchivedDocument.kind(~:15831); the inlinekindquery parameters ofGET /payables/invoices(~:7358),GET /payables/matchable-lines(~:7510, do not add — debit notes are unmatched in v1; leave) andGET /receivables/matchable-lines(~:9529, leave). Line numbers drift: locate each bygrep -n "customer_credit_note\|supplier_credit_note" api/openapi.yaml. Also update the imports description (~:15738-15779) to say opening documents may bedebit_note(AR/AP) if EX-* adds it. -
Step 2: Run the drift tests to see them fail
Run: go test ./internal/gen/... -count=1
Expected: FAIL — generated code is stale (make generate not run).
- Step 3: Regenerate
Run: make generate
Then go run ./tools/publicapi to refresh api/public-openapi.yaml (adding enum values is not a breaking change in tools/publicapi/compare.go).
- Step 4: Run to verify
Run: go build ./... && go test ./internal/gen/... ./tools/publicapi/... ./internal/app/... ./internal/platform/webhooks/... -count=1 -p 1 && pnpm -C web exec vitest run src/drift
Expected: PASS. If go build reports a non-exhaustive switch on the generated enum, that site belongs to the AR/AP/EX tasks; note it and continue.
-
Step 5: Watch it fail once — hand-edit one generated enum constant, run
go test ./internal/gen/... -count=1, read the drift message,git checkoutthat generated file, re-runmake generate. -
Step 6: Commit
git add api/openapi.yaml api/public-openapi.yaml internal/gen web/src/lib/api/schema.d.tsgit commit -m "API: debit-note kinds in the OpenAPI contract; regenerate" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”AP debit note (supplier_debit_note) - implementation plan section
Section titled “AP debit note (supplier_debit_note) - implementation plan section”Spec: docs/superpowers/specs/2026-10-05-debit-notes-design.md. All paths below are relative to the repo root and every line
reference was read on 2026-10-05 against main (15e31c90 plus the working-tree edits listed in git status).
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Assumptions (provided by other plan sections; this section does not build them)
Section titled “Assumptions (provided by other plan sections; this section does not build them)”- Migration (one, next free number) has already run in the test database, with these AP effects:
purchase_invoice_kind_knownacceptssupplier_debit_note; the original-link CHECK (purchase_invoice_original_only_credit_note, name kept) allows an optionaloriginal_invoice_idonsupplier_debit_note; theap_allocationtarget trigger accepts asupplier_debit_noteasinvoice_idtarget;archived_document_kind_knownacceptssupplier_debit_note. Tasks AP-4, AP-5 and AP-6 fail on a DB without it. - Workflow:
workflow.AccrualOnlyKinds(internal/platform/workflow/model.go:58) gainssupplier_debit_note. Graph validity (workflowtest.RequireSeededGraphValid) does not depend on it (validate.go:474only adds a check), so AP-1 can land first. internal/modules/payables/errors.go:130keeps mapping the constraint namepurchase_invoice_original_only_credit_note; its message text (“only a credit note references an original invoice”) becomes slightly stale for a debit note, but that constraint is only a backstop behindcheckInput(AP-4). Cosmetic, left to the API/web section.- The e-invoice, tax return, openapi, web, AR, printing and webhooks sections own everything else. Notably the self-billed
payment gate
selfBilledPayable(transition.go, readseinvoice_submission.purchase_document_id) already works for a debit note once the e-invoice section queues type13submissions.
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Decisions (stated, with the recommendation requested)
Section titled “Decisions (stated, with the recommendation requested)”- invoiceShares: do not call it for a debit note.
reprice(invoice.go:664) callsinvoiceShares/workflow.SetSharesonly forKindInvoice; credit notes already skip it. Shares exist solely to drive theaccount_approvergrant, which the validator permits only forworkflow.SignoffKinds(validate.go:221,model.go:53), anddocumentGraphgrants only the OWNER seat. Adding the kind toSignoffKindswould be a workflow-engine change beyond the spec. Revisit when a customer asks for per-account sign-off on supplier extra charges: changeinv.Kind == KindInvoicetoinv.Kind != KindCreditNoteatinvoice.go:664and append the kind toSignoffKindstogether. AP-1’s test pins the current decision. - Opening documents (AP): not offered in v1. An unpaid supplier debit note at the cut-over has the same ledger effect as an
opening purchase invoice (Dr Migration clearing, Cr Trade payables, positive aging), so it is migrated as
invoice. NoOB-SDNprefix, nodebit_notetype inimports/documents.go.CreateOpeningInvoice(opening.go:56-60) already refuses an unknown kind withkind_known; AP-9 pins that refusal. - Assets register: exclude.
internal/platform/assets/asset.go:228and:406filteri.kind = 'purchase_invoice', so a debit note’s lines are never candidates. No code change; AP-11 adds the regression guard. - Withholding register: include the debit note in the credit-date lookup (
register.go:62), because a payment against a debit note can withhold tax and its “credited” date is the debit note’s accrual date (AP-10). - Delivery/GRN matching: not in v1 (spec “Out of scope”).
matchedKind[KindDebitNote]is the zero value"", which today refuses by accident; AP-8 makes the refusal explicit and tested. - Original link: optional; when present it must be a
KindInvoiceof the same supplier whose workflow state has semanticaccrual(issued, not voided) andcancelled_at IS NULL. A self-billed debit note’s original, when present, must additionally be a self-billed invoice (mirrors D13checkSelfBilledOriginal); a self-billed debit note does not require an original (the spec says optional, unlike a self-billed credit note). No amount cap. - Prefix
SDN: spec-mandated. Note “Sdn Bhd” collisions are only visual (references areSDN-2026-0001);numbering.Allocatescopes by kind string, so there is no collision withPI/CN. - No payablestest change is required to file a debit note (
Env.Invoice(t, payables.KindDebitNote, ...)works once AP-2 lands); AP-2 adds one convenience helperPostedDebitNote, used by AP-3, AP-5, AP-6, AP-7, AP-11.
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Task order
Section titled “Task order”AP-1 constant + graph -> AP-2 prefix/guard/due date -> AP-3 posting -> AP-4 original -> AP-5 payment target -> AP-6 credit-note offset target -> AP-7 aging -> AP-8 matching refusal -> AP-9 opening pin -> AP-10 WHT register -> AP-11 assets guard.
All new payables tests live in one new file internal/platform/payables/debit_note_test.go (package payables_test), grown
task by task. AP-2 creates it; later tasks add functions and, where stated, imports. Run only touched packages (AGENTS.md); DB
tests use payablestest.New -> testdb.Start (shared container, fresh database per test).
Task 7: AP-1 — Kind constant, source kind and seeded workflow graph
Section titled “Task 7: AP-1 — Kind constant, source kind and seeded workflow graph”Files:
- Modify:
internal/platform/payables/doc.go(package comment L1-7; const block L12-37 afterKindCreditNoteat L14;SourceKindsat L46) - Modify:
internal/platform/payables/graphs.go(initL11-21; graph comment L47-52; new func afterCreditNoteGraphL77-79) - Modify:
internal/platform/payables/graphs_test.go(TestTheSeededGraphsAreValidL14-18; append a test)
Interfaces:
-
Produces:
payables.KindDebitNote = "supplier_debit_note",payables.DebitNoteGraph(ownerSeat uuid.UUID) workflow.Graph,payables.SourceKindscontainingKindDebitNote. -
Consumes:
documentGraph(kind, ownerSeat, posted)(graphs.go:46),workflow.RegisterSeededGraph(workflow/seed.go:135). -
Step 1: Write the failing tests. In
graphs_test.gochange the loop at L15 to include the new kind, and append a test:
func TestTheSeededGraphsAreValid(t *testing.T) { for _, k := range []string{payables.KindInvoice, payables.KindCreditNote, payables.KindDebitNote, payables.KindPayment, payables.KindRemittance} { workflowtest.RequireSeededGraphValid(t, k) }}
// The spec: a supplier debit note has the invoice graph's shape, registered as its own kind. It posts one accrual (never// paid by its own flow), void reverses it, and it is not a sign-off kind (see plan AP decision "invoiceShares").func TestTheDebitNoteGraphPostsOnceAndVoidReverses(t *testing.T) { g := payables.DebitNoteGraph(uuid.New()) require.Equal(t, payables.KindDebitNote, g.Kind) var accruals, payouts int for _, s := range g.States { switch s.Semantic { case posting.Accrual: accruals++ require.Equal(t, payables.ControlKind, s.ControlKind) case posting.Payout: payouts++ } } require.Equal(t, 1, accruals, "PRESENCE: the graph has its accrual state") require.Zero(t, payouts) e, ok := g.Edge("approved", "voided") require.True(t, ok) require.True(t, e.ReversesAccrual) require.Contains(t, payables.SourceKinds, payables.KindDebitNote) require.NotContains(t, workflow.SignoffKinds, payables.KindDebitNote)}-
Step 2: Run to see it fail.
go test ./internal/platform/payables/ -run 'TestTheSeededGraphsAreValid|TestTheDebitNoteGraph' -count=1Expected: build failureundefined: payables.KindDebitNote/undefined: payables.DebitNoteGraph. -
Step 3: Implement.
doc.go: package comment L2-3purchase invoices and supplier credit notes (one table,->purchase invoices, supplier credit notes and supplier debit notes (one table,. In the const block add after L14:
KindDebitNote = "supplier_debit_note"and replace L46:
var SourceKinds = []string{KindInvoice, KindCreditNote, KindDebitNote, KindPayment, KindRemittance}graphs.go: in init() add after L13 workflow.RegisterSeededGraph(KindCreditNote, CreditNoteGraph):
workflow.RegisterSeededGraph(KindDebitNote, DebitNoteGraph)comment above documentGraph L47: shared by invoices and credit notes (D5) -> shared by invoices, credit notes and debit notes (D5). After CreditNoteGraph add:
// DebitNoteGraph is the invoice flow under its own kind (debit-notes spec): the supplier bills more, the preparer issues// directly, and approval accrues like an invoice.func DebitNoteGraph(ownerSeat uuid.UUID) workflow.Graph { return documentGraph(KindDebitNote, ownerSeat, ls("Posted", "已过账", "Telah dipos"))}- Step 4: Run to pass. Same command. Expected:
ok. - Step 5: Commit.
git add internal/platform/payables/doc.go internal/platform/payables/graphs.go internal/platform/payables/graphs_test.gogit commit -m "payables: supplier_debit_note kind and seeded graph" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 8: AP-2 — Prefix SDN, CreateInvoice kind guard, due date by terms
Section titled “Task 8: AP-2 — Prefix SDN, CreateInvoice kind guard, due date by terms”Files:
- Modify:
internal/platform/payables/invoice.go(DueOncomment L139;prefixesL196; guard message L572; create due logic L586-589; update due logic L753-756) - Modify:
internal/platform/payables/payablestest/env.go(add helper afterVoid, L168-170 region) - Create:
internal/platform/payables/debit_note_test.go
Interfaces:
-
Consumes:
payables.KindDebitNote(AP-1),payablestest.Env.Invoice/InvoiceOn/Post/Account,expensestest.Today. -
Produces:
prefixes[KindDebitNote] = "SDN";(*payablestest.Env).PostedDebitNote(t, s payables.Supplier, no, net string) payables.Invoice. -
Step 1: Write the failing test. Create
internal/platform/payables/debit_note_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
// A debit note is numbered SDN and, like an invoice and unlike a credit note, falls due by the supplier's terms (also after// an edit).func TestADebitNoteIsNumberedSDNAndDueByTheSupplierTerms(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") // 30-day terms acct := e.Account(t, "6140") cn := e.Invoice(t, payables.KindCreditNote, s, "CN-001", payablestest.Line(acct, "5.00")) require.Equal(t, expensestest.Today, cn.DueOn, "PRESENCE: a credit note is due at once, so the next assertion is not vacuous")
dn := e.Invoice(t, payables.KindDebitNote, s, "DN-001", payablestest.Line(acct, "25.00")) require.Equal(t, payables.KindDebitNote, dn.Kind) require.Regexp(t, `^SDN-2026-\d{4}$`, dn.Reference) require.Equal(t, "25.0000", dn.GrossAmount.String()) require.Equal(t, expensestest.Today.AddDate(0, 0, 30), dn.DueOn)
later := expensestest.Today.AddDate(0, 0, 5) var upd payables.Invoice require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error upd, err = payables.UpdateInvoice(ctx, tx, e.Clock, dn.ID, e.Staff, dn.Revision, payables.InvoiceInput{ SupplierID: s.PartyID, SupplierInvoiceNo: "DN-001", InvoiceDate: later, Currency: "MYR", DocumentID: dn.DocumentID, DocumentBasis: dn.DocumentBasis, RelaxationReason: dn.RelaxationReason, Lines: []payables.LineInput{payablestest.Line(acct, "25.00")}}) return err })) require.Equal(t, later.AddDate(0, 0, 30), upd.DueOn, "an edit keeps the terms")}-
Step 2: Run to see it fail.
go test ./internal/platform/payables/ -run TestADebitNoteIsNumberedSDNAndDueByTheSupplierTerms -count=1Expected: FAIL ate.Invoicefor the debit note withfield=kind rule=kind_known: a purchase invoice or a supplier credit note. -
Step 3: Implement.
invoice.go:- L139:
// nil: InvoiceDate + supplier terms (invoice); InvoiceDate (credit note)->// nil: InvoiceDate + supplier terms (invoice, debit note); InvoiceDate (credit note) - L196:
var prefixes = map[string]string{KindInvoice: "PI", KindCreditNote: "CN", KindDebitNote: "SDN"} - L572:
"a purchase invoice, a supplier credit note or a supplier debit note" - L586-589 and again L753-756 (both blocks are identical): replace
- L139:
due := in.InvoiceDate if in.Kind == KindInvoice { due = in.InvoiceDate.AddDate(0, 0, sup.PaymentTermsDays) }with
due := in.InvoiceDate if in.Kind != KindCreditNote { // an invoice and a debit note fall due by the supplier's terms; a credit note is due at once due = in.InvoiceDate.AddDate(0, 0, sup.PaymentTermsDays) }(CreateInvoice has already refused unknown kinds at L570-573; UpdateInvoice takes in.Kind = before.Kind.)
payablestest/env.go: after func (e *Env) Void(...) add
// PostedDebitNote files and posts a supplier debit note of net on 6140 (no original). The test must have registered the// payables Poster (UsePayablesPoster).func (e *Env) PostedDebitNote(t *testing.T, s payables.Supplier, no, net string) payables.Invoice { t.Helper() return e.Post(t, e.Invoice(t, payables.KindDebitNote, s, no, Line(e.Account(t, "6140"), net)))}- Step 4: Run to pass. Same command. Expected
ok. Alsogo test ./internal/platform/payables/ -run 'TestCreateInvoiceTotalsFreezesBaseAndDefaultsTheDueDate|TestUpdateInvoiceReplaces' -count=1stillok. - Step 5: Commit.
git add internal/platform/payables/invoice.go internal/platform/payables/payablestest/env.go internal/platform/payables/debit_note_test.gogit commit -m "payables: SDN prefix, kind guard and terms-based due date for debit notes" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 9: AP-3 — Posting rule, label and worker wiring (posts unmirrored)
Section titled “Task 9: AP-3 — Posting rule, label and worker wiring (posts unmirrored)”Files:
- Modify:
internal/platform/payables/posting.go(Rules()L31-34;labelmap L67) - Modify:
cmd/worker/wiring_test.go(TestWorkerLinksThePayablesModuletable L160-161) - Modify:
internal/platform/payables/debit_note_test.go(append test; add importledger)
Interfaces:
-
Consumes:
AccrualRule{KindName}(posting.go:36-44),mirror := inv.Kind == KindCreditNote(posting.go:56, unchanged, which is what keeps a debit note unmirrored),sumSideandrequireBalancedWithPartyOnControl(posting_test.go:20,28),payablestest.PostedDebitNote. -
Produces:
payables.Rules()containingAccrualRule{KindName: KindDebitNote}; journalsource_kind = supplier_debit_note, description prefixSupplier debit note. -
Step 1: Write the failing tests. Add
"github.com/mcom/erp/internal/platform/accounting/ledger"to the import block ofdebit_note_test.go, then append:
// Dr expense, Cr Trade payables: the invoice's direction, not the credit note's mirror.func TestADebitNotePostsUnmirrored(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") dn := e.PostedDebitNote(t, s, "DN-001", "40.00") entry := e.EntryFor(t, dn.InstanceID, ledger.SemanticAccrual) requireBalancedWithPartyOnControl(t, e, entry, s.PartyID) _, cr := sumSide(entry, e.Account(t, "2105")) require.Equal(t, "40.0000", cr.String(), "PRESENCE: Trade payables is credited") dr, _ := sumSide(entry, e.Account(t, "2105")) require.Equal(t, "0.0000", dr.String(), "nothing debits Trade payables: never mirrored") dr, _ = sumSide(entry, e.Account(t, "6140")) require.Equal(t, "40.0000", dr.String(), "Dr the expense") require.Equal(t, "supplier_debit_note", *entry.SourceKind) require.Equal(t, "40.0000", e.ControlBalanceByParty(t)[s.PartyID].String(), "we owe more") e.Void(t, dn) require.Equal(t, "0.0000", e.ControlBalanceByParty(t)[s.PartyID].String(), "void reverses it")}In cmd/worker/wiring_test.go L160 change the first table row to
{"purchase_invoice", posting.Accrual}, {"supplier_credit_note", posting.Accrual}, {"supplier_debit_note", posting.Accrual}, {"supplier_payment", posting.Payout},(keep {"wht_remittance", posting.Payout}, on the next line).
-
Step 2: Run to see them fail.
go test ./internal/platform/payables/ -run TestADebitNotePostsUnmirrored -count=1-> FAIL ine.Post(approve transition): “this document cannot be posted” / no posting rule forsupplier_debit_note/accrual.go test ./cmd/worker/ -run TestWorkerLinksThePayablesModule -count=1-> FAILno accrual posting rule for supplier_debit_note. -
Step 3: Implement.
posting.go:
func Rules() []accounting.PostingRule { return []accounting.PostingRule{AccrualRule{KindName: KindInvoice}, AccrualRule{KindName: KindCreditNote}, AccrualRule{KindName: KindDebitNote}, PaymentPayoutRule{}, RemittancePayoutRule{}}}and the label at L67:
label := map[string]string{KindInvoice: "Purchase invoice ", KindCreditNote: "Supplier credit note ", KindDebitNote: "Supplier debit note "}[inv.Kind]Also update the doc comment on AccrualRule (L36-37) to posts an invoice or a debit note (...) or, for a credit note, its mirror.
- Step 4: Run to pass. Same two commands. Expected
ok. Alsogo test ./internal/platform/payables/ -run 'TestCreditNoteMirrorsTheAccrual|TestInvoiceAccrualSplits' -count=1. - Step 5: Commit.
git add internal/platform/payables/posting.go internal/platform/payables/debit_note_test.go cmd/worker/wiring_test.gogit commit -m "payables: post supplier debit notes like invoices" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 10: AP-4 — Optional original_invoice_id (issued, uncancelled, same supplier; self-billed rules)
Section titled “Task 10: AP-4 — Optional original_invoice_id (issued, uncancelled, same supplier; self-billed rules)”Files:
- Modify:
internal/platform/payables/invoice.go(Invoice.OriginalInvoiceIDcomment L90-93;InvoiceInputcomment L148;errOriginalOnlyCreditNoteL337-339; new func aftercheckSelfBilledOriginalL343-360;checkInputself-billed branch L419-425 and non-self-billed branch L451) - Modify:
internal/platform/payables/debit_note_test.go(append tests; add importsuuid,money)
Interfaces:
-
Consumes:
GetInvoice,Invoice.Semantic == posting.Accrual,Invoice.CancelledAt,BasisSelfBilled; test helperse.EInvoiceInScope,e.Doc,e.Post,e.Void. -
Produces:
checkDebitNoteOriginal(ctx, tx, supplier uuid.UUID, originalID *uuid.UUID, selfBilled bool) error; refusalfield=original_invoice_id rule=original_invoice. -
Step 1: Write the failing tests. Add
"github.com/google/uuid"and"github.com/mcom/erp/internal/kernel/money"to the imports ofdebit_note_test.go, then append:
func requireOriginalRefused(t *testing.T, err error) { t.Helper() var fe *payables.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "original_invoice_id", fe.Field) require.Equal(t, "original_invoice", fe.Rule)}
// The original is optional; when given it is an issued, uncancelled invoice of the same supplier.func TestADebitNoteOriginalIsOptionalAndMustBeAnIssuedInvoiceOfTheSameSupplier(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s, other := e.Supplier(t, "BEKAL"), e.Supplier(t, "LAIN") acct := e.Account(t, "6140") file := func(supplier payables.Supplier, original *uuid.UUID) (payables.Invoice, error) { doc, basis, reason := e.Doc(t), "relaxation_receipt", "Supplier issued a paper invoice" var dn payables.Invoice err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error dn, err = payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: payables.KindDebitNote, SupplierID: supplier.PartyID, SupplierInvoiceNo: "DN-" + uuid.NewString()[:8], InvoiceDate: expensestest.Today, Currency: "MYR", DocumentID: &doc, DocumentBasis: &basis, RelaxationReason: &reason, OriginalInvoiceID: original, Lines: []payables.LineInput{payablestest.Line(acct, "10.00")}}) return err }) return dn, err } post := func(kind string, supplier payables.Supplier, no string) payables.Invoice { return e.Post(t, e.Invoice(t, kind, supplier, no, payablestest.Line(acct, "100.00"))) }
dn, err := file(s, nil) require.NoError(t, err, "PRESENCE: no original at all saves") require.Nil(t, dn.OriginalInvoiceID) inv := post(payables.KindInvoice, s, "INV-1") dn, err = file(s, &inv.ID) require.NoError(t, err, "PRESENCE: a posted invoice of the same supplier saves") require.Equal(t, inv.ID, *dn.OriginalInvoiceID)
draft := e.Invoice(t, payables.KindInvoice, s, "INV-DRAFT", payablestest.Line(acct, "10.00")) _, err = file(s, &draft.ID) requireOriginalRefused(t, err) // not issued foreign := post(payables.KindInvoice, other, "INV-OTHER") _, err = file(s, &foreign.ID) requireOriginalRefused(t, err) // another supplier's voided := e.Void(t, post(payables.KindInvoice, s, "INV-VOID")) _, err = file(s, &voided.ID) requireOriginalRefused(t, err) // cancelled cn := post(payables.KindCreditNote, s, "CN-1") _, err = file(s, &cn.ID) requireOriginalRefused(t, err) // not an invoice prior := post(payables.KindDebitNote, s, "DN-PRIOR") _, err = file(s, &prior.ID) requireOriginalRefused(t, err) // not an invoice: no chains, and never itself}
// A self-billed debit note needs no original (spec: optional), but one it names must be an approved self-billed invoice.func TestASelfBilledDebitNoteOriginalIsOptionalButMustBeSelfBilled(t *testing.T) { e := payablestest.New(t) e.EInvoiceInScope(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") basis, code := payables.BasisSelfBilled, "036" line := func() []payables.LineInput { return []payables.LineInput{{Description: "Consulting", AccountID: e.Account(t, "6140"), NetAmount: money.MustParse("100.00"), TaxAmount: money.Zero(), ClassificationCode: &code}} } file := func(kind, no string, original *uuid.UUID) (payables.Invoice, error) { doc := e.Doc(t) var inv payables.Invoice err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: kind, SupplierID: s.PartyID, SupplierInvoiceNo: no, InvoiceDate: expensestest.Today, Currency: "MYR", DocumentID: &doc, DocumentBasis: &basis, OriginalInvoiceID: original, Lines: line()}) return err }) return inv, err } _, err := file(payables.KindDebitNote, "SBDN-1", nil) require.NoError(t, err, "PRESENCE: no original saves") selfBilled, err := file(payables.KindInvoice, "SB-1", nil) require.NoError(t, err) selfBilled = e.Post(t, selfBilled) _, err = file(payables.KindDebitNote, "SBDN-2", &selfBilled.ID) require.NoError(t, err, "PRESENCE: an approved self-billed original saves")
ordinary := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-PLAIN", payablestest.Line(e.Account(t, "6140"), "10.00"))) _, err = file(payables.KindDebitNote, "SBDN-3", &ordinary.ID) requireOriginalRefused(t, err) // issued and ours, but not self-billed}-
Step 2: Run to see them fail.
go test ./internal/platform/payables/ -run 'TestADebitNoteOriginal|TestASelfBilledDebitNoteOriginal' -count=1Expected: first test FAILs atrequire.Equal(t, inv.ID, *dn.OriginalInvoiceID)(nil pointer: the non-self-billed branch atinvoice.go:451discards the original) ; second FAILs at “an approved self-billed original saves” withrule=original_only_credit_note. -
Step 3: Implement. In
invoice.go:- L90-93 comment:
// OriginalInvoiceID is D13: a self-billed credit note's original (an approved self-billed invoice of the same supplier), or the optional invoice a debit note adds to. Never set on an invoice. - L148:
// OriginalInvoiceID: D13, a self-billed credit note; optional on a debit note. - L337-339:
- L90-93 comment:
// errOriginalOnlyCreditNote is CHECK purchase_invoice_original_only_credit_note, in Go.var errOriginalOnlyCreditNote = fieldErr("original_invoice_id", "original_only_credit_note", "only a credit note or a debit note references an original invoice")- after
checkSelfBilledOriginal(ends L360) add:
// checkDebitNoteOriginal: a debit note's original is optional; when given it is an issued, uncancelled invoice of the same// supplier (a voided invoice has left the accrual state), and for a self-billed debit note a self-billed one (D13). No amount// cap: a debit note may exceed what it adds to.func checkDebitNoteOriginal(ctx context.Context, tx pgx.Tx, supplier uuid.UUID, originalID *uuid.UUID, selfBilled bool) error { if originalID == nil { return nil } fail := fieldErr("original_invoice_id", "original_invoice", "a debit note references an issued invoice of the same supplier that is not cancelled") orig, err := GetInvoice(ctx, tx, *originalID) switch { case errors.Is(err, ErrNotFound): return fail case err != nil: return err case orig.Kind != KindInvoice, orig.SupplierID != supplier, orig.Semantic != posting.Accrual, orig.CancelledAt != nil: return fail case selfBilled && (orig.DocumentBasis == nil || *orig.DocumentBasis != BasisSelfBilled): return fieldErr("original_invoice_id", "original_invoice", "a self-billed debit note references the approved self-billed invoice it adds to") } return nil}- self-billed branch, replace L419-425 (
if in.Kind == KindCreditNote { ... } else if in.OriginalInvoiceID != nil { return nil, errOriginalOnlyCreditNote }) with:
switch in.Kind { case KindCreditNote: if err := checkSelfBilledOriginal(ctx, tx, in.SupplierID, in.OriginalInvoiceID); err != nil { return nil, err } case KindDebitNote: if err := checkDebitNoteOriginal(ctx, tx, in.SupplierID, in.OriginalInvoiceID, true); err != nil { return nil, err } default: if in.OriginalInvoiceID != nil { return nil, errOriginalOnlyCreditNote } }- non-self-billed branch, replace L451
in.OriginalInvoiceID = nilwith:
if in.Kind == KindDebitNote { if err := checkDebitNoteOriginal(ctx, tx, in.SupplierID, in.OriginalInvoiceID, false); err != nil { return nil, err } } else { in.OriginalInvoiceID = nil }UpdateInvoice persists in.OriginalInvoiceID already (L788, L792) and CreateInvoice inserts it (L632-636); no change there. A debit note cannot name itself on update: it is not a KindInvoice.
- Step 4: Run to pass. Same command, then the existing guards
go test ./internal/platform/payables/ -run 'TestSelfBilled|TestASelfBilled' -count=1. Expectedok. - Step 5: Commit.
git add internal/platform/payables/invoice.go internal/platform/payables/debit_note_test.gogit commit -m "payables: optional validated original invoice on a supplier debit note" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 11: AP-5 — A debit note is a payment target; void refused while allocated
Section titled “Task 11: AP-5 — A debit note is a payment target; void refused while allocated”Files:
- Modify:
internal/platform/payables/payment.go(checkPaymentline check L251-252) - Modify:
internal/platform/payables/debit_note_test.go(append test)
Interfaces:
-
Consumes:
outstandinghelper (allocation_test.go:15),e.Pay,e.Payment,e.VoidPayment,payables.ErrHasAllocations(invoice.go:34),payables.TransitionInvoice;InvoiceOutstandingalready readsinvoice_idfor any non-credit-note (allocation.go:83-86), so the debit note needs no change there. Needs assumption 1 (allocation trigger). -
Produces:
checkPaymentacceptingKindDebitNotelines. -
Step 1: Write the failing test. Append to
debit_note_test.go:
func TestAPaymentAllocatesAgainstADebitNoteAndItsVoidIsRefusedWhileAllocated(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "BEKAL") dn := e.PostedDebitNote(t, s, "DN-1", "100.00") free := e.PostedDebitNote(t, s, "DN-2", "10.00")
p := e.Pay(t, e.Payment(t, s, "40.00", nil, payables.PaymentLine{InvoiceID: dn.ID, Amount: money.MustParse("40.00")})) require.Equal(t, "60.0000", outstanding(t, e, dn.ID))
err := e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: dn.ID, From: "approved", To: "voided", Actor: e.Owner, Reason: "test"}) return err }) require.ErrorIs(t, err, payables.ErrHasAllocations) e.Void(t, free) // PRESENCE: a debit note with nothing allocated voids
e.VoidPayment(t, p) require.Equal(t, "100.0000", outstanding(t, e, dn.ID), "voiding the payment releases the debit note") e.Void(t, dn) require.Equal(t, "0.0000", e.ControlBalanceByParty(t)[s.PartyID].String())}-
Step 2: Run to see it fail.
go test ./internal/platform/payables/ -run TestAPaymentAllocatesAgainstADebitNote -count=1Expected: FAIL ine.Payment(payablestest.Env.PaymentWithWHTrequires no error):ErrInvoiceNotPayable“only this supplier’s posted invoices in the payment’s currency can be paid”. -
Step 3: Implement.
payment.goL251-252 changeinv.Kind != KindInvoicetoinv.Kind == KindCreditNote:
if err != nil || inv.Kind == KindCreditNote || inv.SupplierID != in.SupplierID || inv.Currency != in.Currency || !inv.AccrualOpen {Also update ErrInvoiceNotPayable text (L30) to only this supplier's posted invoices and debit notes in the payment's currency can be paid (no test asserts the string; checked by grep of internal and web).
- Step 4: Run to pass. Same command, plus
go test ./internal/platform/payables/ -run 'TestPayment|TestCreatePayment' -count=1(existing payment tests). Expectedok. - Step 5: Commit.
git add internal/platform/payables/payment.go internal/platform/payables/debit_note_test.gogit commit -m "payables: a supplier debit note is a valid payment target" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 12: AP-6 — Credit-note offset against a debit note; debit note refused as a source
Section titled “Task 12: AP-6 — Credit-note offset against a debit note; debit note refused as a source”Files:
- Modify:
internal/platform/payables/allocation.go(ErrNotAllocatableL193;AllocateCreditNotetarget check L224-226) - Modify:
internal/platform/payables/debit_note_test.go(append test)
Interfaces:
-
Consumes:
allocate,reverse,outstandinghelpers (allocation_test.go:15,28,37 region),payables.ErrOverAllocation,payables.ErrNotAllocatable; the source checkcn.Kind != KindCreditNote(L217) already refuses a debit note as source, so only the target check changes. -
Produces:
AllocateCreditNoteaccepting aKindDebitNotetarget. -
Step 1: Write the failing test.
// A credit note offsets a debit note like an invoice; a debit note is never the source of an allocation.func TestACreditNoteOffsetsADebitNoteButADebitNoteIsNeverASource(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") acct := e.Account(t, "6140") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(acct, "100.00"))) cn := e.Post(t, e.Invoice(t, payables.KindCreditNote, s, "CN-1", payablestest.Line(acct, "50.00"))) dn := e.PostedDebitNote(t, s, "DN-1", "30.00")
_, err := allocate(e, cn, inv, "10.00") require.NoError(t, err, "PRESENCE: the credit note offsets an invoice") a, err := allocate(e, cn, dn, "30.00") require.NoError(t, err, "and a debit note") require.Equal(t, "0.0000", outstanding(t, e, dn.ID)) require.Equal(t, "10.0000", outstanding(t, e, cn.ID), "50 less 10 and 30") _, err = allocate(e, cn, dn, "1.00") require.ErrorIs(t, err, payables.ErrOverAllocation, "nothing is left on the debit note")
_, err = allocate(e, dn, inv, "5.00") require.ErrorIs(t, err, payables.ErrNotAllocatable, "a debit note adds to what is owed; it offsets nothing") require.Equal(t, "90.0000", outstanding(t, e, inv.ID), "and the refusal left the invoice alone")
require.NoError(t, reverse(e, a.ID)) require.Equal(t, "30.0000", outstanding(t, e, dn.ID), "reversal restores the debit note")}-
Step 2: Run to see it fail.
go test ./internal/platform/payables/ -run TestACreditNoteOffsetsADebitNote -count=1Expected: FAIL at “and a debit note”:payables: a posted credit note offsets a posted invoice of the same supplier and currency(ErrNotAllocatable). -
Step 3: Implement.
allocation.goL225:
case inv.Kind == KindCreditNote || !inv.AccrualOpen, cn.SupplierID != inv.SupplierID, cn.Currency != inv.Currency:and L193 message: payables: a posted credit note offsets a posted invoice or debit note of the same supplier and currency. Also L189 more than the invoice has outstanding is unchanged. (A credit note against a credit note is still refused by inv.Kind == KindCreditNote.)
- Step 4: Run to pass. Same command plus
go test ./internal/platform/payables/ -run 'TestOverAllocation|TestACreditNoteOffsetsOnly' -count=1. Expectedok. - Step 5: Commit.
git add internal/platform/payables/allocation.go internal/platform/payables/debit_note_test.gogit commit -m "payables: credit notes may offset a supplier debit note" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 13: AP-7 — Aging join (debit note ages positive and nets its allocations)
Section titled “Task 13: AP-7 — Aging join (debit note ages positive and nets its allocations)”Files:
- Modify:
internal/platform/payables/aging.go(agingSQLappliedjoin L121; doc comment L106-108) - Modify:
internal/platform/payables/debit_note_test.go(append test; uses theaginghelper in aging_test.go:28)
Interfaces:
-
Consumes:
aging(t, e, asOf)(aging_test.go:28),AgingRow.Outstanding/Kind,expensestest.Today. The signCASE(L126-127) tests only= 'supplier_credit_note', so a debit note already takes the positive branch; only the join is wrong (it matches neither arm, so allocations never reduce a debit note). -
Produces:
appliedCTE matchinga.invoice_id = p.idforsupplier_debit_note. -
Step 1: Write the failing test.
// A debit note ages as money owed (positive) and is reduced by what is allocated to it.func TestADebitNoteAgesPositiveAndZeroesOncePaid(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "BEKAL") dn := e.PostedDebitNote(t, s, "DN-1", "100.00")
a := aging(t, e, expensestest.Today) require.Len(t, a.Rows, 1) require.Equal(t, payables.KindDebitNote, a.Rows[0].Kind) require.Equal(t, "100.0000", a.Rows[0].Outstanding.String(), "positive: we owe it") require.Equal(t, "100.0000", a.Totals.Total.String())
e.Pay(t, e.Payment(t, s, "40.00", nil, payables.PaymentLine{InvoiceID: dn.ID, Amount: money.MustParse("40.00")})) a = aging(t, e, expensestest.Today) require.Len(t, a.Rows, 1) require.Equal(t, "60.0000", a.Rows[0].Outstanding.String(), "reduced by the payment")
e.Pay(t, e.Payment(t, s, "60.00", nil, payables.PaymentLine{InvoiceID: dn.ID, Amount: money.MustParse("60.00")})) require.Empty(t, aging(t, e, expensestest.Today).Rows, "aging shows zero once paid")}-
Step 2: Run to see it fail.
go test ./internal/platform/payables/ -run TestADebitNoteAgesPositive -count=1Expected: the first block passes (positive, 100.0000: it is the “watch it age positive” presence) and the test FAILs at"reduced by the payment": got100.0000, want60.0000. -
Step 3: Implement.
aging.goL121:
AND ((p.kind IN ('purchase_invoice', 'supplier_debit_note') AND a.invoice_id = p.id) OR (p.kind = 'supplier_credit_note' AND a.credit_note_id = p.id))Doc comments: L106-108 A credit note's remainder is negative. stays true; add A debit note ages as an invoice. and change AgingRow comment L72 to one open document (invoice, debit note or credit note).
- Step 4: Run to pass. Same command plus
go test ./internal/platform/payables/ -run 'TestAging|TestHistoricalAging|TestBucket' -count=1. Expectedok. - Step 5: Commit.
git add internal/platform/payables/aging.go internal/platform/payables/debit_note_test.gogit commit -m "payables: age supplier debit notes net of their allocations" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 14: AP-8 — Debit notes are not matched to receipts (explicit clean refusal)
Section titled “Task 14: AP-8 — Debit notes are not matched to receipts (explicit clean refusal)”Files:
- Modify:
internal/platform/payables/match.go(matchedLineopening switch L117-122; leavematchedKindL98 andMatchableLinesL373 as is) - Modify:
internal/platform/payables/debit_note_test.go(append test)
Interfaces:
-
Consumes:
matchedKind(match.go:98),LineInput.TradeLineID/Quantity,MatchableLines(ctx, tx, supplierID, kind)(match.go:~290-373, already refuses an unknown kind withfield=kind rule=kind_known). -
Produces: refusal
lines[0].trade_line_id rule=trade_line_kindwith a detail naming debit notes. Today the refusal happens only becausematchedKind[KindDebitNote]is""and no trade document has an empty kind; this makes it deliberate and independent of the DB lookup. -
Step 1: Write the failing test.
// Delivery matching is for invoices and credit notes (spec: out of scope for a debit note).func TestADebitNoteIsNeverMatchedToAReceipt(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") acct := e.Account(t, "6140") trade, qty := uuid.New(), money.MustParseRate("1") file := func(kind, no string) error { return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: kind, SupplierID: s.PartyID, SupplierInvoiceNo: no, InvoiceDate: expensestest.Today, Currency: "MYR", Lines: []payables.LineInput{{ Description: "Bolts", AccountID: acct, NetAmount: money.MustParse("10.00"), TaxAmount: money.Zero(), TradeLineID: &trade, Quantity: &qty}}}) return err }) } var fe *payables.FieldError require.ErrorAs(t, file(payables.KindInvoice, "M-1"), &fe) require.Equal(t, "trade_line", fe.Rule, "PRESENCE: an invoice reaches the receipt lookup and finds no such line") require.ErrorAs(t, file(payables.KindDebitNote, "M-2"), &fe) require.Equal(t, "lines[0].trade_line_id", fe.Field) require.Equal(t, "trade_line_kind", fe.Rule) require.Contains(t, fe.Detail, "debit note")
err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.MatchableLines(ctx, tx, s.PartyID, payables.KindDebitNote) return err }) require.ErrorAs(t, err, &fe) require.Equal(t, "kind_known", fe.Rule, "the picker refuses cleanly too, no panic")}-
Step 2: Run to see it fail.
go test ./internal/platform/payables/ -run TestADebitNoteIsNeverMatchedToAReceipt -count=1Expected: FAIL at the debit note’srequire.Equal(t, "trade_line_kind", fe.Rule)with"trade_line"(the unknown-line lookup answers first) . -
Step 3: Implement. In
matchedLine, make the firstswitch(L117) start with an explicit case:
switch { case in.Kind == KindDebitNote: return uuid.Nil, fieldErr(f("trade_line_id"), "trade_line_kind", "a debit note is not matched to a receipt or a return") case l.Quantity == nil || !l.Quantity.IsPositive():(leave the remaining cases unchanged). The picker needs no change: MatchableLines already returns kind_known when matchedKind[kind] is absent (match.go:372-374).
- Step 4: Run to pass. Same command plus
go test ./internal/platform/payables/ -run 'TestAThreeWayMismatch|TestMatch' -count=1. Expectedok. - Step 5: Commit.
git add internal/platform/payables/match.go internal/platform/payables/debit_note_test.gogit commit -m "payables: refuse delivery matching on a supplier debit note explicitly" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 15: AP-9 — Opening documents: debit notes are not an opening kind (pin the decision)
Section titled “Task 15: AP-9 — Opening documents: debit notes are not an opening kind (pin the decision)”Files:
- Modify:
internal/platform/payables/debit_note_test.go(append test) - No production change (decision above):
opening.go:32openingPrefixesand:56-60already refuse an unknown kind.
Interfaces:
-
Consumes:
CreateOpeningInvoice(ctx, tx, c, actor, OpeningInput{...})(opening.go:~48),OpeningInput.Kind/SupplierID/LegacyReference/DocumentDate/Outstanding/PostOn,e.NopPoster. -
Step 1: Write the test.
// Decision (plan AP): a supplier debit note brought forward is an opening invoice; there is no OB-SDN.func TestAnOpeningDebitNoteIsNotAnOpeningKind(t *testing.T) { e := payablestest.New(t) e.NopPoster(t) s := e.Supplier(t, "BEKAL") open := func(kind string) error { return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateOpeningInvoice(ctx, tx, e.Clock, e.Staff, payables.OpeningInput{Kind: kind, SupplierID: s.PartyID, LegacyReference: "OLD-" + kind, DocumentDate: expensestest.Today.AddDate(0, 0, -10), Outstanding: money.MustParse("100.00"), PostOn: expensestest.Today}) return err }) } require.NoError(t, open(payables.KindInvoice), "PRESENCE: the same input opens as an invoice") var fe *payables.FieldError require.ErrorAs(t, open(payables.KindDebitNote), &fe) require.Equal(t, "kind", fe.Field) require.Equal(t, "kind_known", fe.Rule)}- Step 2: Run it, then watch it fail once.
go test ./internal/platform/payables/ -run TestAnOpeningDebitNoteIsNotAnOpeningKind -count=1-> expectedok(no code change). To prove the guard bites, temporarily addKindDebitNote: "OB-SDN"toopeningPrefixes(opening.go:32), re-run, expect FAIL (open(KindDebitNote)no longer returns a FieldError), then revert that edit (git checkout internal/platform/payables/opening.go). IfCreateOpeningInvoicedemands migration fixtures the presence call lacks, copy them fromgoLiveWorld(internal/platform/imports/documents_test.go). - Step 3: Implement. Nothing.
- Step 4: Re-run to pass. Same command. Expected
ok. - Step 5: Commit.
git add internal/platform/payables/debit_note_test.gogit commit -m "payables: pin that a debit note is not an opening kind" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 16: AP-10 — Withholding register names and credit-dates a debit note
Section titled “Task 16: AP-10 — Withholding register names and credit-dates a debit note”Files:
- Modify:
internal/platform/payables/register.go(LATERAL join L62) - Modify:
internal/platform/payables/debit_note_test.go(append test; add importtime)
Interfaces:
-
Consumes:
invoiceAt-style helpers in register_test.go (withholdW109BL33,paymentAtL40,registerL69,rowForL79),e.InvoiceOn,e.AdvanceClock,RegisterRow.InvoiceReferences/CreditedOn/CreditedEarlier. -
Produces:
je.source_kind IN ('purchase_invoice', 'supplier_debit_note')in the register’s credited-date lookup.inv.refsalready names any allocated document because it joinspurchase_invoice pi2bya.invoice_id. -
Step 1: Write the failing test (mirrors
TestWithholdingRegisterCreditedEarlier, register_test.go:168). Add"time"to the imports ofdebit_note_test.go:
func TestWithholdingRegisterCreditDatesADebitNoteToo(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "SGSVC") t0 := time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC) // expensestest.Today dn := e.Post(t, e.InvoiceOn(t, payables.KindDebitNote, s, "R-DN", t0, payablestest.Line(e.Account(t, "6170"), "1000.00"))) e.AdvanceClock(t, 40) later := t0.AddDate(0, 0, 40) p := paymentAt(t, e, s, later, withholdW109B(t, e), payables.PaymentLine{InvoiceID: dn.ID, Amount: money.MustParse("1000.00")})
r, ok := rowFor(register(t, e, later, t0, later, "s109b"), p.Withholding[0].ID) require.True(t, ok) require.Equal(t, []string{dn.Reference}, r.InvoiceReferences, "PRESENCE: the register names the debit note") require.NotNil(t, r.CreditedOn, "its accrual date is the credit date") require.Equal(t, t0, *r.CreditedOn) require.True(t, r.CreditedEarlier)}-
Step 2: Run to see it fail.
go test ./internal/platform/payables/ -run TestWithholdingRegisterCreditDatesADebitNoteToo -count=1Expected: FAIL atrequire.NotNil(t, r.CreditedOn)(the references line passes, which is the presence). -
Step 3: Implement.
register.goL62:
LEFT JOIN journal_entry je ON je.org_id = $1 AND je.source_kind IN ('purchase_invoice', 'supplier_debit_note')- Step 4: Run to pass. Same command plus
go test ./internal/platform/payables/ -run TestWithholdingRegister -count=1. Expectedok. - Step 5: Commit.
git add internal/platform/payables/register.go internal/platform/payables/debit_note_test.gogit commit -m "payables: withholding register credit-dates payments against debit notes" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 17: AP-11 — Assets register: a debit note’s lines are never asset candidates (guard)
Section titled “Task 17: AP-11 — Assets register: a debit note’s lines are never asset candidates (guard)”Files:
- Modify:
internal/platform/assets/asset_test.go(append test afterTestAPostedPurchaseLineRegistersOnceAndPostsNothing, L16-39) - No production change (decision above):
internal/platform/assets/asset.go:228(readSource) and:406(Candidates) filteri.kind = 'purchase_invoice'.
Interfaces:
-
Consumes: assets test
world(w.PPE,w.Class,w.register,jan15),payablestest.Env.InvoiceOn/Post/PostedDebitNote… notePostedDebitNoteposts on6140; this test needsw.PPE, so it files the debit note itself.assets.Candidates,assets.ErrSourceNotEligible,assets.SourcePurchaseInvoiceLine. -
Step 1: Write the test.
// Decision (payables debit-note plan): a supplier debit note is an extra charge, not a capitalisable purchase, so its lines are// not asset candidates and do not register. The posted invoice line beside it is the presence case.func TestADebitNoteLineIsNotAnAssetCandidate(t *testing.T) { w := newWorld(t) s := w.Supplier(t, "DELL") inv := w.Post(t, w.InvoiceOn(t, payables.KindInvoice, s, "INV-1", jan15, payablestest.Line(w.PPE, "12000"))) dn := w.Post(t, w.InvoiceOn(t, payables.KindDebitNote, s, "DN-1", jan15, payablestest.Line(w.PPE, "500")))
offered := map[string]bool{} w.Tx(t, func(ctx context.Context, tx pgx.Tx) error { cs, err := assets.Candidates(ctx, tx) for _, c := range cs { offered[c.SourceID.String()] = true } return err }) require.True(t, offered[inv.Lines[0].ID.String()], "PRESENCE: the invoice line is offered") require.False(t, offered[dn.Lines[0].ID.String()])
register := func(id uuid.UUID) error { _, err := w.register(t, assets.RegisterInput{SourceKind: assets.SourcePurchaseInvoiceLine, SourceID: id, ClassID: w.Class.ID, Description: "Laptops", InServiceOn: jan15}) return err } require.NoError(t, register(inv.Lines[0].ID), "PRESENCE: the invoice line registers") require.ErrorIs(t, register(dn.Lines[0].ID), assets.ErrSourceNotEligible)}Add "github.com/google/uuid" to the import block of asset_test.go if absent.
- Step 2: Run, then watch it fail once.
go test ./internal/platform/assets/ -run TestADebitNoteLineIsNotAnAssetCandidate -count=1-> expectedok. To prove the guard bites: temporarily change asset.go L228 and L406i.kind = 'purchase_invoice'toi.kind IN ('purchase_invoice', 'supplier_debit_note'), re-run, expect FAIL (require.False(offered[dn...])), then revert withgit checkout internal/platform/assets/asset.go. - Step 3: Implement. Nothing.
- Step 4: Re-run to pass. Same command. Expected
ok. - Step 5: Commit.
git add internal/platform/assets/asset_test.gogit commit -m "assets: guard that supplier debit note lines are not asset candidates" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Closing check (after AP-11, once, before merging)
Section titled “Closing check (after AP-11, once, before merging)”go test ./internal/platform/payables/... ./internal/platform/assets/... ./internal/platform/imports/... ./cmd/worker/... -count=1 -p 1
(then the single full go test ./... -p 1 run per AGENTS.md, after the other plan sections have landed). Drift guards that touch this area and need the migration section: tenant-isolation and “every constraint has a down” tests live with the migration, not here.
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Debit notes: MyInvois, SST return, printing, imports/archive (tasks EX-1 to EX-11)
Section titled “Debit notes: MyInvois, SST return, printing, imports/archive (tasks EX-1 to EX-11)”Part of the debit-note implementation plan (spec docs/superpowers/specs/2026-10-05-debit-notes-design.md).
All paths relative to /Users/limziyang/Documents/repos/mcomtask. Line refs verified by reading the tree at HEAD 15e31c90
plus the uncommitted working changes listed in git status (none of them touch these files).
FLAGGED: types
03and13are NOT validated against LHDN’s sandbox. The golden files this plan creates are the encoder’s own output (astestdata/README.mdalready says for 01/02/11), andBillingReferenceon a debit note, plus a debit note waiting on its original’s UUID (D12), are assumptions. EX-11 records this as a ROADMAP open item owned by the adviser, beside the pre-mandate credit-note item (ROADMAP item 11).
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Assumptions from other sections of the plan (consumed, not built here)
Section titled “Assumptions from other sections of the plan (consumed, not built here)”receivables.KindDebitNote = "customer_debit_note",payables.KindDebitNote = "supplier_debit_note".- One DB migration already extends: the kind CHECKs,
tax_return_line_source_kind_known,einvoice_submission_type_known/einvoice_submission_type_matches_source(03, 13),archived_document_kind_known. - AR/AP sections: debit-note graph (invoice shape: AR
draft -> issuedby the preparer; APdraft -> pending_review -> posted),Rules()entries,workflow.AccrualOnlyKinds, allocation/receipt targets,receivables.CreateDocumentacceptsKind: KindDebitNotewith optionalOriginalInvoiceIDandReason,payables.CreateInvoiceacceptsKind: KindDebitNotewith optionalOriginalInvoiceID. - No helper is assumed in
receivablestest/payablestest: every test below builds its debit notes with a local helper (einvoicetest.DebitNote,einvoicetest.SelfBilledDebitNote,debitNotein taxreturn tests,debitNoteOfin webhooks tests).
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Findings that shaped the tasks (so the executor does not re-derive them)
Section titled “Findings that shaped the tasks (so the executor does not re-derive them)”countrypack/my/myinvois/ubl.goneeds no edit:InvoiceTypeCodeisd.Typeverbatim (L41) andBillingReferenceis written wheneverd.Original != nil(L56-64). The check incheck.goalready requires an original only for 02/12 (L34-41), so 03/13 are optional-original by construction; EX-1 pins that with tests. Whatcheck.godoes need: the self-billed test (L29) andselfBilledDefaults(L54) must include type 13, or a foreign supplier on a self-billed debit note gets no defaults.taxreturn/compute.godocument basis (L124) negates onlykind == sourceCreditNote, so a debit note is already positive there and itsSourceKindis its own kind. The real defect is the payment-basis loader (loadPaymentFacts): query 1 (L258) takes onlykind = 'sales_invoice'as an invoice; query 2 (L281) groups bycoalesce(original_invoice_id, id)and the branch at L311 treats every non-invoice row as a credit note. A debit note with an original is therefore netted against the original as a credit; one without an original is dropped. Decision (spec “payment-basis loader decision”): a debit note is its own payment-basis invoice, attributed by its own receipts. TheSourceLineit produces keeps its own kind (L173 hard-codessourceInvoice).- A debit note with an original waits for the original’s e-Invoice to be
valid(originalReady, D12), exactly like a credit note. This is a behaviour, not a preference: LHDN needs the original’s UUID inBillingReference. webhooks/catalog.gois kind-agnostic (salesDocument(ctx, tx, id, kind)L154 takes the kind from the event): no code change, one test (EX-9).imports/attach.goL134 permission-by-kind loopsArchiveSalesKinds, so adding the sales kinds there routes archive-read permission with no edit.
Task 18: EX-1 — MyInvois document types 03 and 13 in the codec
Section titled “Task 18: EX-1 — MyInvois document types 03 and 13 in the codec”Files:
- Modify
internal/platform/einvoice/doc.goL30-33 (type constants). - Modify
internal/platform/countrypack/my/myinvois/check.goL29 (selfBilled), L54 (selfBilledDefaults). - Modify
internal/platform/countrypack/my/myinvois/fixtures_test.go(aftercreditNote(), L51; afterselfBilled(), L68). - Modify
internal/platform/countrypack/my/myinvois/ubl_test.goL41-50 (golden map). - Modify
internal/platform/countrypack/my/myinvois/check_test.go(append tests). - Create (by
-update)internal/platform/countrypack/my/myinvois/testdata/debit_note.json,testdata/self_billed_debit_note.json. - No edit to
ubl.go(see finding 1).
Interfaces:
-
Produces
einvoice.TypeDebitNote = "03",einvoice.TypeSelfBilledDebitNote = "13". -
Test-only:
debitNote(),selfBilledDebitNote()fixtures in packagemyinvois_test. -
Consumes
einvoice.Document,einvoice.Reference,myinvois.Codec{}.Check/Encode. -
Step 1: failing tests. Append to
fixtures_test.goaftercreditNote():
// debitNote references complete's invoice as its original, like a credit note// (types 03 and 13 are not sandbox-validated: testdata/README.md).func debitNote() einvoice.Document { d := complete() d.Type = einvoice.TypeDebitNote d.CodeNumber = "DN-2026-0001" d.Original = &einvoice.Reference{CodeNumber: "INV-2026-0001", UUID: "6f9c9a2e-2b1a-4e6a-8f0a-1234567890ab"} return d}and after selfBilled():
// selfBilledDebitNote is selfBilled's supplier billing us more, with no original.func selfBilledDebitNote() einvoice.Document { d := selfBilled() d.Type = einvoice.TypeSelfBilledDebitNote d.CodeNumber = "SBDN-2026-0001" return d}In ubl_test.go replace the map literal (L42-45) with:
for name, d := range map[string]einvoice.Document{ "invoice": complete(), "credit_note": creditNote(), "self_billed_invoice": selfBilled(), "foreign_invoice": foreignCurrency(), "debit_note": debitNote(), "self_billed_debit_note": selfBilledDebitNote(), } {Append to check_test.go:
// Types 03 and 13 reference an original when they have one but do not need one// (spec: the original is optional on a debit note); a credit note still does.func TestADebitNoteNeedsNoOriginalButACreditNoteDoes(t *testing.T) { require.Empty(t, myinvois.Codec{}.Check(debitNote()), "presence: with its original it passes") d := debitNote() d.Original = nil require.Empty(t, myinvois.Codec{}.Check(d)) body, err := myinvois.Codec{}.Encode(d) require.NoError(t, err) require.NotContains(t, string(body), "BillingReference") require.Contains(t, string(body), `"InvoiceTypeCode":[{"_":"03","listVersionID":"1.0"}]`)
c := creditNote() c.Original = nil require.Contains(t, fields(myinvois.Codec{}.Check(c)), "original", "presence: the credit-note rule still bites")}
// Type 13 gets D13's self-billed defaults and city rule, as 11 and 12 do.func TestASelfBilledDebitNoteGetsTheSelfBilledDefaults(t *testing.T) { d := selfBilledDebitNote() d.Supplier.TIN, d.Supplier.RegistrationType, d.Supplier.RegistrationNo = "", "", "" d.Supplier.MSIC, d.Supplier.BusinessActivity = "", "" require.Empty(t, myinvois.Codec{}.Check(d), "the foreign-supplier defaults fill every gap") body, err := myinvois.Codec{}.Encode(d) require.NoError(t, err) for _, want := range []string{`{"_":"EI00000000030","schemeID":"TIN"}`, `"IndustryClassificationCode":[{"_":"00000","name":"Not applicable"}]`, `"InvoiceTypeCode":[{"_":"13","listVersionID":"1.0"}]`} { require.Contains(t, string(body), want) } d.Supplier.City = "" require.Contains(t, fields(myinvois.Codec{}.Check(d)), "supplier.city")}-
Step 2: run, expect failure.
go test ./internal/platform/countrypack/my/myinvois/ -run 'TestADebitNote|TestASelfBilledDebitNote|TestTheEncoderMatchesTheGoldenFiles'Expected: compile errorundefined: einvoice.TypeDebitNote/einvoice.TypeSelfBilledDebitNote. -
Step 3: implement.
doc.go, in the const block afterTypeCreditNote/TypeSelfBilledCreditNote:
TypeInvoice = "01" TypeCreditNote = "02" TypeDebitNote = "03" TypeSelfBilledInvoice = "11" TypeSelfBilledCreditNote = "12" TypeSelfBilledDebitNote = "13"check.go L29:
selfBilled := d.Type == einvoice.TypeSelfBilledInvoice || d.Type == einvoice.TypeSelfBilledCreditNote || d.Type == einvoice.TypeSelfBilledDebitNotecheck.go L54:
if d.Type != einvoice.TypeSelfBilledInvoice && d.Type != einvoice.TypeSelfBilledCreditNote && d.Type != einvoice.TypeSelfBilledDebitNote {-
Step 4: run, expect the two new check tests to pass and the golden test to fail with
open testdata/debit_note.json: no such file or directory.go test ./internal/platform/countrypack/my/myinvois/ -run 'TestADebitNote|TestASelfBilledDebitNote'passes. Watch it fail once: temporarily revert the L54 edit incheck.go;TestASelfBilledDebitNoteGetsTheSelfBilledDefaultsmust go red (supplier.tinrequired); restore. -
Step 5: generate the two goldens and read them.
go test ./internal/platform/countrypack/my/myinvois/ -run TestTheEncoderMatchesTheGoldenFiles -updateThengrep -o '"InvoiceTypeCode":\[{"_":"[0-9]*"' internal/platform/countrypack/my/myinvois/testdata/debit_note.json internal/platform/countrypack/my/myinvois/testdata/self_billed_debit_note.jsonmust print03and13, andgrep -c BillingReferencemust be 1 fordebit_note.jsonand 0 forself_billed_debit_note.json.git diff --statmust show the four existing goldens unchanged. Re-run without-update: passes. -
Step 6: commit.
git add internal/platform/einvoice/doc.go internal/platform/countrypack/my/myinvois/check.go internal/platform/countrypack/my/myinvois/fixtures_test.go internal/platform/countrypack/my/myinvois/ubl_test.go internal/platform/countrypack/my/myinvois/check_test.go internal/platform/countrypack/my/myinvois/testdata/debit_note.json internal/platform/countrypack/my/myinvois/testdata/self_billed_debit_note.jsongit commit -m "MyInvois: document types 03 and 13 in the codec
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 19: EX-2 — AR debit note is queued as type 03
Section titled “Task 19: EX-2 — AR debit note is queued as type 03”Files:
- Modify
internal/platform/einvoice/build.goL26-35 (salesDocument). - Modify
internal/platform/einvoice/guard.goL16 (registration loop), comment L21 and L80-83. - Modify
internal/platform/einvoice/einvoicetest/env.go(addDebitNote; extendEncode, L292-298). - Create
internal/platform/einvoice/debitnote_test.go.
Interfaces:
-
Consumes
receivables.KindDebitNote,receivables.DocumentInput{Kind, CustomerID, OriginalInvoiceID, Reason, Currency, Lines},receivables.CreateDocument,einvoice.TypeDebitNote(EX-1), test helpers already in packageeinvoice_test:markValid,billingReference,fields. -
Produces
(*einvoicetest.Env).DebitNote(t, c receivables.Customer, original *receivables.Document, reason string, lines ...receivables.LineInput) receivables.Document;Encodenow handles a debit note (originalUUIDvariadic as for a credit note). -
Step 1: failing tests. In
einvoicetest/env.goadd afterEncode(needs no new imports;receivables,pgx,requireare imported):
// DebitNote files a draft customer debit note as e.Staff, in the customer's own// currency (a debit note does not inherit its original's), optionally against original.func (e *Env) DebitNote(t *testing.T, c receivables.Customer, original *receivables.Document, reason string, lines ...receivables.LineInput) receivables.Document { t.Helper() in := receivables.DocumentInput{Kind: receivables.KindDebitNote, CustomerID: c.PartyID, Reason: &reason, Currency: c.Currency, Lines: lines} if original != nil { in.OriginalInvoiceID = &original.ID } var d receivables.Document require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, in) return err })) return d}and in Encode, directly after the existing if d.Kind == receivables.KindCreditNote { ... } block:
if d.Kind == receivables.KindDebitNote { doc.Type = einvoice.TypeDebitNote if d.OriginalInvoiceID != nil { doc.Original = &einvoice.Reference{CodeNumber: *e.Get(t, *d.OriginalInvoiceID).Reference} if len(originalUUID) > 0 { doc.Original.UUID = originalUUID[0] } } }Create internal/platform/einvoice/debitnote_test.go:
package einvoice_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/einvoice" "github.com/mcom/erp/internal/platform/einvoice/einvoicetest" "github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/receivables/receivablestest" "github.com/mcom/erp/internal/platform/workflow")
// Types 03 and 13 are NOT validated against LHDN's sandbox (ROADMAP open item).// Spec: an AR debit note is e-Invoiced as type 03; the preparer issues it directly.func TestADebitNoteIsQueuedAsType03AndReferencesItsValidOriginal(t *testing.T) { e := einvoicetest.New(t) e.InScope(t) c := e.CompleteCustomer(t) line := func(amount string) receivables.LineInput { return receivablestest.Line(e.Revenue, e.ST10, "1", amount) } original := e.Issue(t, e.Invoice(t, c, line("100.00")))
dn := e.DebitNote(t, c, &original, "Freight undercharged", line("20.00")) err := e.TryIssue(t, dn) // the original's e-Invoice is queued, not valid (D12) var ce *einvoice.ConflictError require.ErrorAs(t, err, &ce) require.Equal(t, "original_einvoice_pending", ce.Code) require.Equal(t, "draft", e.State(t, dn)) require.Nil(t, e.SubmissionOrNil(t, dn))
// PRESENCE: once the original is valid the same debit note issues and queues. markValid(t, e, original, "UUID-ORIGINAL") e.Issue(t, dn) s, err := e.SubmissionFor(t, dn) require.NoError(t, err) require.Equal(t, einvoice.StatusQueued, s.Status) require.Equal(t, einvoice.TypeDebitNote, s.DocumentType) number, uid := billingReference(t, s) require.Equal(t, *original.Reference, number) require.Equal(t, "UUID-ORIGINAL", uid) require.Equal(t, e.Encode(t, dn, "UUID-ORIGINAL"), s.DocumentJSON, "stored byte for byte as the Codec encoded it (D14)")}
// The original is optional: with none the document is type 03 and carries no BillingReference.func TestADebitNoteWithoutAnOriginalIsQueuedWithNoBillingReference(t *testing.T) { e := einvoicetest.New(t) e.InScope(t) c := e.CompleteCustomer(t) dn := e.Issue(t, e.DebitNote(t, c, nil, "Interest on overdue balance", receivablestest.Line(e.Revenue, e.ST10, "1", "15.00"))) s, err := e.SubmissionFor(t, dn) require.NoError(t, err) require.Equal(t, einvoice.TypeDebitNote, s.DocumentType) require.NotContains(t, string(s.DocumentJSON), "BillingReference") require.Equal(t, e.Encode(t, dn), s.DocumentJSON)}
// An unregistered kind never queues an e-Invoice, so the same refusal an invoice gets// must apply: incomplete buyer refused in scope, issues out of scope.func TestAnIncompleteBuyerIsRefusedOnADebitNoteInScope(t *testing.T) { e := einvoicetest.New(t) bare := e.Customer(t, "BARE") // no TIN, no phone line := receivablestest.Line(e.Revenue, e.ST10, "1", "10.00") e.Issue(t, e.DebitNote(t, bare, nil, "Freight", line)) // presence: out of scope it issues
e.InScope(t) dn := e.DebitNote(t, bare, nil, "Freight", line) var ce *einvoice.CheckError require.ErrorAs(t, e.TryIssue(t, dn), &ce) require.Contains(t, fields(ce), "buyer.tin") require.Equal(t, "draft", e.State(t, dn))}
// D10: a valid debit note is cancelled at LHDN first; an issued one with no row is corrected by a credit note.func TestADebitNoteFollowsTheVoidTable(t *testing.T) { e := einvoicetest.New(t) c := e.CompleteCustomer(t) line := receivablestest.Line(e.Revenue, e.ST10, "1", "10.00") preScope := e.Issue(t, e.DebitNote(t, c, nil, "Freight", line)) require.Nil(t, e.SubmissionOrNil(t, preScope), "fixture has no row; the refusal below would be vacuous") var ce *einvoice.ConflictError require.ErrorAs(t, e.Try(preScope, e.Owner, "issued", "voided"), &ce) require.Equal(t, "correct_by_credit_note", ce.Code)
e.InScope(t) dn := e.Issue(t, e.DebitNote(t, c, nil, "Freight", line)) e.ForceStatus(t, dn, einvoice.StatusInvalid) require.NoError(t, e.Try(dn, e.Owner, "issued", "voided"), "presence: an invalid e-Invoice voids") dn2 := e.Issue(t, e.DebitNote(t, c, nil, "Freight", line)) e.ForceStatus(t, dn2, einvoice.StatusValid) require.ErrorAs(t, e.Try(dn2, e.Owner, "issued", "voided"), &ce) require.Equal(t, "einvoice_cancel_first", ce.Code)}
var _ = context.Backgroundvar _ pgx.Txvar _ workflow.Graph(The three trailing blank-import guards are removed in EX-4, which uses all three; if you split commits, delete them here and add the imports back in EX-4.)
-
Step 2: run, expect failure.
go test ./internal/platform/einvoice/ -run 'TestADebitNote|TestAnIncompleteBuyerIsRefusedOnADebitNote'Expected:TestADebitNoteWithoutAnOriginalIsQueuedWithNoBillingReferencefails atSubmissionFor:einvoice: not found(no guard is registered forcustomer_debit_note, so nothing was queued);TestADebitNoteIsQueuedAsType03...fails at the firstErrorAs(issue succeeded, no refusal);...IncompleteBuyer...failsErrorAs(issued unchecked). -
Step 3: implement.
guard.goL16:
for _, k := range []string{receivables.KindInvoice, receivables.KindCreditNote, receivables.KindDebitNote} {and update the comment at L21 from “AR’s two kinds” to “AR’s three kinds”. build.go L28-35, replace the credit-note if with:
switch src.Kind { case receivables.KindCreditNote: d.Type = TypeCreditNote original, err := receivables.GetDocument(ctx, tx, *src.OriginalInvoiceID) if err != nil { return Document{}, err } d.Original = &Reference{CodeNumber: deref(original.Reference)} case receivables.KindDebitNote: d.Type = TypeDebitNote if src.OriginalInvoiceID != nil { // optional on a debit note original, err := receivables.GetDocument(ctx, tx, *src.OriginalInvoiceID) if err != nil { return Document{}, err } d.Original = &Reference{CodeNumber: deref(original.Reference)} } }Update the salesDocument doc comment (“a credit note’s submit” stays true; add “a debit note is type 03, referencing its original when it has one”). In guard.go the existing if src.OriginalInvoiceID != nil { ... originalReady(s, &d) } (L64-72) already handles the debit note, because d.Original is non-nil exactly when OriginalInvoiceID is.
-
Step 4: run, expect pass. Same command as Step 2; also
go test ./internal/platform/einvoice/ -run 'TestACreditNote|TestAnInScopeInvoice'(no regression on 02/01). Watch it fail once was Step 2 (guard unregistered). -
Step 5: commit.
git add internal/platform/einvoice/build.go internal/platform/einvoice/guard.go internal/platform/einvoice/einvoicetest/env.go internal/platform/einvoice/debitnote_test.gogit commit -m "einvoice: queue an AR debit note as type 03
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 20: EX-3 — AP self-billed debit note is queued as type 13
Section titled “Task 20: EX-3 — AP self-billed debit note is queued as type 13”Files:
- Modify
internal/platform/einvoice/guard_payables.goL19 (loop), L24-29 comment, L106-117 (purchaseDocument). - Modify
internal/platform/einvoice/einvoicetest/env.go(addSelfBilledDebitNoteafterSelfBilledCreditNote, ends L406). - Modify
internal/platform/einvoice/debitnote_test.go(append tests).
Interfaces:
-
Consumes
payables.KindDebitNote,payables.InvoiceInput{Kind, SupplierID, SupplierInvoiceNo, InvoiceDate, Currency, DocumentID, DocumentBasis, OriginalInvoiceID, Lines},payables.BasisSelfBilled,einvoice.TypeSelfBilledDebitNote, existing helperse.SubmitPurchase,e.ApprovePurchase,e.TryApprovePurchase,e.PurchaseSubmission,e.PurchaseSubmissionOrNil,e.PurchaseInvoice,markPurchaseValid(selfbilled_test.go L17),billingReference. -
Produces
(*einvoicetest.Env).SelfBilledDebitNote(t, sup payables.Supplier, original *payables.Invoice, classificationCode string) payables.Invoice. -
Step 1: failing tests. In
einvoicetest/env.goafterSelfBilledCreditNote:
// SelfBilledDebitNote files a draft self-billed debit note (the supplier billing us// more), one line coded classificationCode, optionally against original.func (e *Env) SelfBilledDebitNote(t *testing.T, sup payables.Supplier, original *payables.Invoice, classificationCode string) payables.Invoice { t.Helper() basis := payables.BasisSelfBilled account := e.Account(t, "6100") doc := e.Document(t, e.Staff, "application/pdf", []byte("self-billed debit "+uuid.NewString())) in := payables.InvoiceInput{Kind: payables.KindDebitNote, SupplierID: sup.PartyID, SupplierInvoiceNo: "SBDN-" + uuid.NewString()[:8], InvoiceDate: expensestest.Today, Currency: sup.Currency, DocumentID: &doc, DocumentBasis: &basis, Lines: []payables.LineInput{{Description: "Additional services", AccountID: account, NetAmount: money.MustParse("25.00"), TaxAmount: money.Zero(), ClassificationCode: &classificationCode}}} if original != nil { in.OriginalInvoiceID = &original.ID } var inv payables.Invoice require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, in) return err })) return inv}Append to debitnote_test.go (add import "github.com/mcom/erp/internal/platform/payables" is not needed; helpers return payables.Invoice by inference):
// D13: a self-billed debit note is checked at submit, queued at approval as type 13,// and waits for its original's e-Invoice like a self-billed credit note.func TestASelfBilledDebitNoteIsQueuedAsType13AtApproval(t *testing.T) { e := einvoicetest.New(t) e.InScope(t) sup := e.ForeignSupplier(t) original := e.SelfBilledInvoice(t, sup, "036") e.SubmitPurchase(t, original) e.ApprovePurchase(t, original) // queued, not yet valid
dn := e.SelfBilledDebitNote(t, sup, &original, "036") e.SubmitPurchase(t, dn) require.Nil(t, e.PurchaseSubmissionOrNil(t, dn), "submit: checked, not queued") var ce *einvoice.ConflictError require.ErrorAs(t, e.TryApprovePurchase(t, dn), &ce) require.Equal(t, "original_einvoice_pending", ce.Code) require.Equal(t, "pending_review", e.PurchaseInvoice(t, dn).State)
// PRESENCE: once the original is valid, the debit note approves and references it. markPurchaseValid(t, e, original, "UUID-ORIGINAL") e.ApprovePurchase(t, dn) s := e.PurchaseSubmission(t, dn) require.Equal(t, einvoice.TypeSelfBilledDebitNote, s.DocumentType) number, uid := billingReference(t, s) require.Equal(t, e.PurchaseInvoice(t, original).Reference, number) require.Equal(t, "UUID-ORIGINAL", uid)}
func TestASelfBilledDebitNoteWithoutAnOriginalCarriesNoBillingReference(t *testing.T) { e := einvoicetest.New(t) e.InScope(t) dn := e.SelfBilledDebitNote(t, e.ForeignSupplier(t), nil, "036") e.SubmitPurchase(t, dn) e.ApprovePurchase(t, dn) s := e.PurchaseSubmission(t, dn) require.Equal(t, einvoice.TypeSelfBilledDebitNote, s.DocumentType) require.NotContains(t, string(s.DocumentJSON), "BillingReference")}
// Out of scope a self-billed debit note is refused at submit, as a self-billed invoice is (T7-R3);// presence: in scope the same fixture submits.func TestASelfBilledDebitNoteNeedsScope(t *testing.T) { e := einvoicetest.New(t) e.InScope(t) dn := e.SelfBilledDebitNote(t, e.ForeignSupplier(t), nil, "036") e.OutOfScope(t) var ce *einvoice.CheckError require.ErrorAs(t, e.TrySubmitPurchase(t, dn), &ce) require.Contains(t, fields(ce), "document_basis") e.InScope(t) require.NoError(t, e.TrySubmitPurchase(t, dn))}-
Step 2: run, expect failure.
go test ./internal/platform/einvoice/ -run 'TestASelfBilledDebitNote'Expected:TestASelfBilledDebitNoteIsQueuedAsType13AtApprovalfails atErrorAs(no guard, approve succeeded or returned nil);...NeedsScopefailsErrorAs(nil error);...WithoutAnOriginalfails inPurchaseSubmission(not found). -
Step 3: implement.
guard_payables.goL19:
for _, k := range []string{payables.KindInvoice, payables.KindCreditNote, payables.KindDebitNote} {Comment L24-26: “self-billed e-Invoices on the AP side … every other purchase invoice, supplier credit note or supplier debit note keeps AP’s own rules untouched”. Replace L106-117 in purchaseDocument:
d := Document{Type: TypeSelfBilledInvoice, CodeNumber: inv.Reference, IssuedAt: c.Now().UTC(), Currency: inv.Currency, FXRate: inv.FXRate} switch inv.Kind { case payables.KindCreditNote: d.Type = TypeSelfBilledCreditNote case payables.KindDebitNote: d.Type = TypeSelfBilledDebitNote } if inv.OriginalInvoiceID != nil { // optional on a debit note; the credit-note case is unchanged original, err := payables.GetInvoice(ctx, tx, *inv.OriginalInvoiceID) if err != nil { return Document{}, err } d.Original = &Reference{CodeNumber: original.Reference} }(For a purchase invoice OriginalInvoiceID is nil by the existing CHECK, so this is behaviour-neutral for 11.) Update the doc comment of purchaseDocument to “self-billed purchase invoice, credit note or debit note”.
-
Step 4: run, expect pass. Same command, then
go test ./internal/platform/einvoice/ -run 'TestASelfBilled|TestAnOrdinaryPurchaseInvoiceIsUntouched|TestTheSelfBilledVoidRule'(no regression on 11/12). -
Step 5: commit.
git add internal/platform/einvoice/guard_payables.go internal/platform/einvoice/einvoicetest/env.go internal/platform/einvoice/debitnote_test.gogit commit -m "einvoice: queue an AP self-billed debit note as type 13
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 21: EX-4 — scope switch-on and publish keep the debit-note void exit
Section titled “Task 21: EX-4 — scope switch-on and publish keep the debit-note void exit”Files:
- Modify
internal/platform/einvoice/setting.goL174 (salesVoidExits), L196 (RegisterPublishCheckloop), comment above L170 (“each sales graph”). - Modify
internal/platform/einvoice/debitnote_test.go(append; this is where thecontext/pgx/workflowimports become used, so delete the threevar _ =guards added in EX-2).
Interfaces:
-
Consumes
receivables.KindDebitNote,receivables.InvoiceGraph(ownerSeat),withoutVoid(g)(setting_test.go L195),workflow.PublishGraph,e.TrySaveSetting,einvoicetest.Complete(). -
Produces nothing new.
-
Step 1: failing tests. Append:
// Final review M7 for the third sales graph: switching in scope refuses while the// debit-note graph has no void exit. Presence first: the seeded graph switches on.func TestSwitchingInScopeNeedsAVoidExitOnTheDebitNoteGraph(t *testing.T) { e := einvoicetest.New(t) require.True(t, e.InScope(t).InScope, "PRESENCE: the seeded graphs have their void") e.OutOfScope(t)
g := withoutVoid(receivables.InvoiceGraph(e.OwnerSeat)) // the debit-note graph has the invoice's shape g.Kind = receivables.KindDebitNote e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := workflow.PublishGraph(ctx, tx, e.Clock, g, nil) return err }) _, err := e.TrySaveSetting(einvoicetest.Complete()) var ce *einvoice.ConflictError require.ErrorAs(t, err, &ce) require.Equal(t, "sales_void_exit_missing", ce.Code) require.Contains(t, ce.Detail, receivables.KindDebitNote)}
// Review minor 3 for the debit-note graph: in scope, one without its void is not published.func TestAnInScopeOrganizationCannotPublishADebitNoteGraphWithoutItsVoid(t *testing.T) { e := einvoicetest.New(t) g := withoutVoid(receivables.InvoiceGraph(e.OwnerSeat)) g.Kind = receivables.KindDebitNote publish := func() error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := workflow.PublishGraph(ctx, tx, e.Clock, g, nil) return err }) } require.NoError(t, publish(), "PRESENCE: out of scope it publishes") restored := receivables.InvoiceGraph(e.OwnerSeat) restored.Kind = receivables.KindDebitNote e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := workflow.PublishGraph(ctx, tx, e.Clock, restored, nil) return err }) e.InScope(t) var ie *workflow.InvalidError require.ErrorAs(t, publish(), &ie) require.Equal(t, "sales_void_exit_missing", ie.Violations[0].Code)}-
Step 2: run, expect failure.
go test ./internal/platform/einvoice/ -run 'TestSwitchingInScopeNeedsAVoidExitOnTheDebitNoteGraph|TestAnInScopeOrganizationCannotPublishADebitNoteGraph'Expected: first test fails atErrorAs(TrySaveSettingreturned nil: the loop never looks at the debit-note graph); second fails at the lastErrorAs(publish succeeded). -
Step 3: implement.
setting.goL174 and L196, both loops:
for _, kind := range []string{receivables.KindInvoice, receivables.KindCreditNote, receivables.KindDebitNote} {-
Step 4: run, expect pass. Same command plus
go test ./internal/platform/einvoice/ -run 'TestSwitchingInScopeNeedsAVoidExitOnBothSalesGraphs|TestAnInScopeOrganizationCannotPublishASalesGraph'. Then run the whole package once:go test ./internal/platform/einvoice/... ./internal/platform/countrypack/my/...(DB tests: usetestdb.Start, per-package only). -
Step 5: commit.
git add internal/platform/einvoice/setting.go internal/platform/einvoice/debitnote_test.gogit commit -m "einvoice: the debit-note graph keeps its void exit in scope
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 22: EX-5 — SST return does not net a debit note as a credit
Section titled “Task 22: EX-5 — SST return does not net a debit note as a credit”Files:
- Modify
internal/platform/taxreturn/compute.goL18-22 (constants), L173 (payment-basisSourceLine), L255-271 (query 1 and its scan), L281-290 (query 2), L311 (branch), doc comment L242-249. - Modify
internal/platform/taxreturn/attribute.goL30-38 (invoiceFacts: addKind). - Modify
internal/platform/taxreturn/compute_test.go(append tests and a local helper).
Interfaces:
-
Consumes
receivables.KindDebitNote,receivables.CreateDocument/DocumentInput, existing test helpersworld,line,sepOct,rows,linesOf,receipt(compute_test.go L100),e.Issue,e.Invoice,e.IssueCreditNote,e.CreditNote, constantsr10,r8. -
Produces constant
sourceDebitNote = "customer_debit_note"(unexported),invoiceFacts.Kind string. -
Step 1: failing tests. Append to
compute_test.go(add import"github.com/jackc/pgx/v5"is already present viacompute()’s closure; add"github.com/mcom/erp/internal/platform/taxreturn/taxreturntest"already imported):
// debitNote files a draft debit note against original, in the original's currency.func debitNote(t *testing.T, e *taxreturntest.Env, original receivables.Document, reason string, lines ...receivables.LineInput) receivables.Document { t.Helper() var d receivables.Document require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{ Kind: receivables.KindDebitNote, CustomerID: original.CustomerID, OriginalInvoiceID: &original.ID, Reason: &reason, Currency: original.Currency, Lines: lines}) return err })) return d}
// A debit note raises the tax charged: a positive line of its own kind, never a credit,// even though it carries original_invoice_id (the loader's "non-invoice with an original is a credit note" assumption).func TestADebitNoteIsAPositiveLineEvenWithItsOriginal(t *testing.T) { e, reg, cu := world(t) a := e.Issue(t, e.Invoice(t, cu, line(t, e, "ST10", "1000.00"))) // PRESENCE: the invoice alone is reported as 1000 / 100. require.Equal(t, map[string]string{r10: "1000.0000 / 100.0000 (1)"}, rows(t, sepOct(t, e, reg), "sales_tax"))
dn := e.Issue(t, debitNote(t, e, a, "Freight undercharged", line(t, e, "ST10", "100.00"))) c := sepOct(t, e, reg) require.Equal(t, map[string]string{r10: "1100.0000 / 110.0000 (2)"}, rows(t, c, "sales_tax")) require.Equal(t, "110.0000", c.TaxPayable.String()) require.Equal(t, "0.0000", c.Reconciliation.Difference.String(), "AR posted the debit note's tax unmirrored") kinds := map[string]string{} for _, l := range c.Lines { if l.FieldKey == "sales_tax" { kinds[l.Reference] = l.SourceKind } } require.Equal(t, map[string]string{*a.Reference: "sales_invoice", *dn.Reference: "customer_debit_note"}, kinds)
// A credit note against the same invoice still nets negative beside it. e.IssueCreditNote(t, e.CreditNote(t, a, "Goods returned", line(t, e, "ST10", "50.00"))) require.Equal(t, map[string]string{r10: "1050.0000 / 105.0000 (3)"}, rows(t, sepOct(t, e, reg), "sales_tax"))}
// Payment basis (service tax): a debit note is its own invoice, attributed by its own receipts;// it is neither netted against its original as a credit nor dropped.func TestADebitNoteIsItsOwnPaymentBasisInvoice(t *testing.T) { e, reg, cu := world(t) inv := e.Issue(t, e.Invoice(t, cu, line(t, e, "SVT8", "100.00"))) dn := e.Issue(t, debitNote(t, e, inv, "Additional hours", line(t, e, "SVT8", "50.00"))) // PRESENCE: nothing paid, nothing due, but the debit note's tax is deferred beside the invoice's (8 + 4). c := sepOct(t, e, reg) require.Empty(t, rows(t, c, "service_tax")) require.Equal(t, "12.0000", c.Reconciliation.Deferred.String())
receipt(t, e, cu, inv, "54.00") // half of the invoice receipt(t, e, cu, dn, "54.00") // all of the debit note c = sepOct(t, e, reg) require.Equal(t, map[string]string{r8: "100.0000 / 8.0000 (2)"}, rows(t, c, "service_tax")) require.ElementsMatch(t, []string{ *inv.Reference + " " + r8 + ": 50.0000 / 4.0000", *dn.Reference + " " + r8 + ": 50.0000 / 4.0000", }, linesOf(c, "service_tax")) kinds := map[string]string{} for _, l := range c.Lines { if l.FieldKey == "service_tax" { kinds[l.Reference] = l.SourceKind } } require.Equal(t, map[string]string{*inv.Reference: "sales_invoice", *dn.Reference: "customer_debit_note"}, kinds)}(compute_test.go needs "context" already imported: yes, L4.)
-
Step 2: run, expect failure.
go test ./internal/platform/taxreturn/ -run 'TestADebitNote'Expected:TestADebitNoteIsItsOwnPaymentBasisInvoicefails: the firstrequire.Equal(... "12.0000")gets8.0000or4.0000(the debit note was netted into the invoice as a credit: the invoice’s net tax is 4, deferred 4), and the rows assertion shows50.0000 / 4.0000 (1).TestADebitNoteIsAPositiveLineEvenWithItsOriginalpasses on first run (document basis already positive); it needs its own watch-it-fail, Step 5. -
Step 3: implement.
compute.goconstants (L18-22):
const ( sourceInvoice = "sales_invoice" sourceCreditNote = "customer_credit_note" sourceDebitNote = "customer_debit_note" sourceAdjustment = "adjustment")Doc comment of loadPaymentFacts (L242-244): “every issued invoice or debit note dated <= end with lines of packKind, with those lines, its issued credit notes’ lines … (a debit note is its own invoice: it is not a credit note even when it names an original)”.
Query 1 (L256-262): change the WHERE clause and add d.kind to the select list and scan:
rows, err := tx.Query(ctx, ` SELECT d.id, d.kind, d.reference, d.invoice_date, d.gross_amount FROM sales_invoice d WHERE d.org_id = $1 AND d.kind IN ($4, $5) AND d.issued_at IS NOT NULL AND d.cancelled_at IS NULL AND d.invoice_date <= $2 AND EXISTS (SELECT 1 FROM sales_invoice_line l WHERE l.org_id = $1 AND l.invoice_id = d.id AND l.withdrawn_at IS NULL AND l.tax_pack_kind = $3) ORDER BY d.invoice_date, d.reference`, org, end, packKind, sourceInvoice, sourceDebitNote)and L267 rows.Scan(&f.ID, &f.Kind, &f.Reference, &f.Date, &f.Gross).
Query 2 (L280-290): only a credit note is attributed to its original:
SELECT CASE WHEN d.kind = $4 THEN coalesce(d.original_invoice_id, d.id) ELSE d.id END, d.kind, d.id, d.invoice_date, d.gross_amount, l.tax_rate, sum(l.net_base), sum(l.tax_base) FROM sales_invoice d LEFT JOIN sales_invoice_line l ON l.org_id = $1 AND l.invoice_id = d.id AND l.withdrawn_at IS NULL AND l.tax_pack_kind = $3 WHERE d.org_id = $1 AND d.issued_at IS NOT NULL AND d.cancelled_at IS NULL AND d.invoice_date <= $2 GROUP BY 1, 2, 3, 4, 5, 6`, org, end, packKind, sourceCreditNote)Branch at L311:
if kind != sourceCreditNote { // an invoice or a debit note: its own linesattribute.go invoiceFacts: add Kind string as the second field. compute.go L173: SourceKind: f.Kind.
-
Step 4: run, expect pass.
go test ./internal/platform/taxreturn/ -run 'TestADebitNote|TestServiceTax|TestACreditFromAnotherInvoice|TestSalesTax|TestDeferred|TestAReceipt'— the existing payment-basis tests must stay green. -
Step 5: watch the document-basis guard fail once. Temporarily change
if kind == sourceCreditNote {(L124) toif kind != sourceInvoice {:TestADebitNoteIsAPositiveLineEvenWithItsOriginalmust go red (1000.0000 / 100.0000plus-100.0000 / -10.0000); restore. -
Step 6: commit.
git add internal/platform/taxreturn/compute.go internal/platform/taxreturn/attribute.go internal/platform/taxreturn/compute_test.gogit commit -m "taxreturn: a debit note is a positive line, not a credit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 23: EX-6 — a finalised return freezes a debit note under its own source kind
Section titled “Task 23: EX-6 — a finalised return freezes a debit note under its own source kind”Files:
- Modify
internal/platform/taxreturn/return_test.go(append afterTestFinaliseFreezesTheFigures, which ends before L191). - No production edit: this pins the migration’s
tax_return_line_source_kind_knownandreturn.goL544’s insert.
Interfaces: consumes create, finalise, taxreturn.SourceLines, debitNote (EX-5), world, line.
- Step 1: test.
// A debit note's line is frozen under its own kind (tax_return_line_source_kind_known), positive.func TestFinaliseFreezesADebitNoteUnderItsOwnKind(t *testing.T) { e, _, cu := world(t) a := e.Issue(t, e.Invoice(t, cu, line(t, e, "ST10", "1000.00"))) dn := e.Issue(t, debitNote(t, e, a, "Freight undercharged", line(t, e, "ST10", "100.00"))) r, err := create(t, e, "2026-09-01", "") require.NoError(t, err) e.At(t, "2026-11-01") r, err = finalise(t, e, r.ID, r.Revision) require.NoError(t, err) require.Equal(t, "110.0000", r.TaxPayable.String())
var lines []taxreturn.SourceLine e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error lines, err = taxreturn.SourceLines(ctx, tx, e.Clock, r.ID, "") return err }) require.Len(t, lines, 2) // presence: both documents are frozen byRef := map[string]taxreturn.SourceLine{} for _, l := range lines { byRef[l.Reference] = l } require.Equal(t, "sales_invoice", byRef[*a.Reference].SourceKind) require.Equal(t, "customer_debit_note", byRef[*dn.Reference].SourceKind) require.Equal(t, "10.0000", byRef[*dn.Reference].Tax.String())}-
Step 2: run.
go test ./internal/platform/taxreturn/ -run TestFinaliseFreezesADebitNote. Expected on a tree without the migration:violates check constraint "tax_return_line_source_kind_known"; with the migration: passes. (This is the “watch it fail once”: run it before applying the migration to a scratch DB, or verify by temporarily dropping the constraint’s new value.) -
Step 3: commit.
git add internal/platform/taxreturn/return_test.gogit commit -m "taxreturn: pin a debit note's frozen source kind
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 24: EX-7 — printing titles (English and Malay)
Section titled “Task 24: EX-7 — printing titles (English and Malay)”Files:
- Modify
internal/platform/printing/render.goL14 (labels map). - Modify
internal/platform/printing/documents.goL25-29 (struct and comment), L56-58, L70, L77-80. - Modify
internal/platform/printing/layout.goL28 (comment only). - Modify
internal/modules/receivables/pdf.goL72. - Create
internal/platform/printing/export_test.go. - Modify
internal/platform/printing/printing_test.go(append).
Interfaces:
-
Produces
printing.Invoice.DebitNote bool; label key"debit_note"= {“Debit note”, “Nota debit”}; test-onlyprinting.Title. -
Consumes
receivables.KindDebitNote(aliasp.KindDebitNotein pdf.go),printing.RenderInvoice,printing.StatutoryInvoiceFields,printing.Default. -
A debit note prints due date and payment terms like an invoice (
!v.CreditNoteguards at L88/L90 stay as they are); it prints the Reason line when one is present. -
Step 1: failing tests. Create
internal/platform/printing/export_test.go:
package printing
// Title is title, for the external tests: the heading a document prints.var Title = titleAppend to printing_test.go:
func TestADebitNoteIsTitledInEnglishAndMalay(t *testing.T) { en := printing.Layout{Language: printing.LanguageEnglish} ms := printing.Layout{Language: printing.LanguageMalay} require.Equal(t, "Credit note", printing.Title(en, "credit_note")) // presence: the same call names a credit note require.Equal(t, "Debit note", printing.Title(en, "debit_note")) require.Equal(t, "Nota debit", printing.Title(ms, "debit_note")) require.Equal(t, "Debit note / Nota debit", printing.Title(printing.Layout{Language: printing.LanguageBoth}, "debit_note"))}
// A debit note keeps the statutory lines, and prints its reason when it has one; an invoice prints none.func TestADebitNotePrintsItsReasonAndRenders(t *testing.T) { inv := printing.Invoice{Number: "DN-1", Currency: "MYR", Reason: "Freight undercharged"} l := printing.Layout{Language: printing.LanguageEnglish} joined := func(v printing.Invoice) string { var b strings.Builder for _, f := range printing.StatutoryInvoiceFields(v, l) { b.WriteString(f[0] + "=" + f[1] + ";") } return b.String() } require.NotContains(t, joined(inv), "Reason=", "presence: an invoice with a stray reason prints no reason line") inv.DebitNote = true require.Contains(t, joined(inv), "Reason=Freight undercharged;") require.NotContains(t, joined(printing.Invoice{DebitNote: true, Number: "DN-2"}), "Reason=", "no reason, no line")
pdf, err := printing.RenderInvoice(inv, printing.Settings{Layout: printing.Default(printing.KindSalesDocument)}) require.NoError(t, err) require.True(t, bytes.HasPrefix(pdf, []byte("%PDF")))}Note the first NotContains asserts the pre-change/neutral behaviour for a plain invoice; it needs a reason-bearing invoice to be meaningful, which it has (Reason set, flags unset).
-
Step 2: run, expect failure.
go test ./internal/platform/printing/ -run 'TestADebitNote'Expected: compile errorunknown field DebitNote in struct literal. -
Step 3: implement.
render.goL14:"invoice": {"Invoice", "Invois"}, "credit_note": {"Credit note", "Nota kredit"}, "debit_note": {"Debit note", "Nota debit"},.documents.gostruct (afterCreditNote bool):DebitNote bool; comment L25: “a sales invoice, credit note or debit note as printed”. L56:
if v.CreditNote || (v.DebitNote && v.Reason != "") {L77-80:
key := "invoice" switch { case v.CreditNote: key = "credit_note" case v.DebitNote: key = "debit_note" }RenderInvoice doc comment: “invoice, credit note or debit note”. layout.go L28 comment: // invoice, credit note and debit note. pdf.go L72:
v := printing.Invoice{CreditNote: d.Kind == p.KindCreditNote, DebitNote: d.Kind == p.KindDebitNote, Number: *d.Reference, Date: *d.InvoiceDate, Due: d.DueOn,-
Step 4: run, expect pass.
go test ./internal/platform/printing/ ./internal/modules/receivables/ -run 'TestADebitNote|TestACreditNoteWithASparseSnapshotRenders|TestEveryKindRendersItsSample|TestAStrippedLayout'. Limit: thepdf.gomapping (DebitNote: d.Kind == p.KindDebitNote) is compile-checked only;renderDocumentreturns opaque PDF bytes, so nothing asserts the title came out. If a reviewer wants it pinned, extractprinting.Titleuse into a returned struct; not done (scope). -
Step 5: commit.
git add internal/platform/printing/render.go internal/platform/printing/documents.go internal/platform/printing/layout.go internal/platform/printing/export_test.go internal/platform/printing/printing_test.go internal/modules/receivables/pdf.gogit commit -m "printing: a debit note title in English and Malay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 25: EX-8 — historical archive kinds for debit notes
Section titled “Task 25: EX-8 — historical archive kinds for debit notes”Files:
- Modify
internal/platform/imports/archive.goL35-36 (kind lists), L50-51 and L54-55 (sales side), L59-60 and L63-64 (purchase side), L149 (type-name hint). - Modify
internal/platform/imports/archive_test.goL38, L41 (exact counts), append test. - No edit to
attach.go: L134 loopsArchiveSalesKinds, so archive-read permission follows (finding 5).
Interfaces:
-
Produces
ArchiveSalesKindsincluding"customer_debit_note",ArchivePurchaseKindsincluding"supplier_debit_note"; file typedebit_note; count key"debit_notes". -
Consumes the migration’s
archived_document_kind_known. Trade-link pairs: not extended (spec default). -
Step 1: failing tests. Edit L38 to
map[string]int{"invoices": 1, "credit_notes": 0, "debit_notes": 0, "receipts": 1, "lines": 2}and L41 tomap[string]int{"invoices": 1, "credit_notes": 0, "debit_notes": 0, "payments": 0, "lines": 1}. Append:
// A settled debit note is archived read-only under its own kind, never posted;// one already imported as an open item is refused, as for the other kinds.func TestADebitNoteIsArchivedUnderItsOwnKind(t *testing.T) { w := goLiveWorld(t) before := 0 w.tx(t, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT count(*) FROM journal_entry`).Scan(&before) }) res, err := w.run(t, imports.KindArchiveSalesDocuments, "type,number,date,customer,total,line_description,line_amount\n"+ "debit_note,DN-9001,2025-06-30,MAJU,200.00,Freight,200.00\n") require.NoError(t, err) require.Equal(t, map[string]int{"invoices": 0, "credit_notes": 0, "debit_notes": 1, "receipts": 0, "lines": 1}, res.Counts) res, err = w.run(t, imports.KindArchivePurchaseDocuments, "type,number,date,supplier,total\ndebit_note,PDN-3001,2025-05-02,PEMBEKAL,80.00\n") require.NoError(t, err) require.Equal(t, map[string]int{"invoices": 0, "credit_notes": 0, "debit_notes": 1, "payments": 0, "lines": 0}, res.Counts)
w.tx(t, func(ctx context.Context, tx pgx.Tx) error { sales, _, err := imports.ListArchived(ctx, tx, imports.ArchiveFilter{Kinds: imports.ArchiveSalesKinds}) require.NoError(t, err) require.Len(t, sales, 1) require.Equal(t, "customer_debit_note", sales[0].Kind) purchases, _, err := imports.ListArchived(ctx, tx, imports.ArchiveFilter{Kinds: imports.ArchivePurchaseKinds}) require.NoError(t, err) require.Len(t, purchases, 1) require.Equal(t, "supplier_debit_note", purchases[0].Kind) var after int require.NoError(t, tx.QueryRow(ctx, `SELECT count(*) FROM journal_entry`).Scan(&after)) require.Equal(t, before, after, "an archived document never posts") return nil })
// PRESENCE above: the first import landed. ABSENCE: the same number is now refused. _, err = w.run(t, imports.KindArchiveSalesDocuments, "type,number,date,customer,total\ndebit_note,DN-9001,2025-06-30,MAJU,200.00\n") require.Equal(t, "DN-9001 is already in the archive", problems(t, err)[0].Reason)}-
Step 2: run, expect failure.
go test ./internal/platform/imports/ -run 'TestADebitNoteIsArchived|TestTheArchiveKeeps'Expected:the type is invoice, credit_note, receiptproblem on the new test; the old test’s counts assertion fails on the extra key only after step 3 if not edited (already edited). -
Step 3: implement.
archive.go:
ArchiveSalesKinds = []string{"sales_invoice", "customer_credit_note", "customer_debit_note", "customer_receipt"} ArchivePurchaseKinds = []string{"purchase_invoice", "supplier_credit_note", "supplier_debit_note", "supplier_payment"}archiveSales (L49-57):
types: map[string]string{"invoice": "sales_invoice", "credit_note": "customer_credit_note", "debit_note": "customer_debit_note", "receipt": "customer_receipt"}, plural: map[string]string{"invoice": "invoices", "credit_note": "credit_notes", "debit_note": "debit_notes", "receipt": "receipts"}, live: map[string]string{ "sales_invoice": ...unchanged, "customer_credit_note": ...unchanged, "customer_receipt": ...unchanged, }}archivePurchases (L58-65) likewise with "debit_note": "supplier_debit_note", plural "debit_notes", (no live entry for a debit note: there is no opening debit note to clash with).
L149: for _, t := range []string{"invoice", "credit_note", "debit_note", "receipt", "payment"} {.
-
Step 4: run, expect pass.
go test ./internal/platform/imports/(this package only; DB tests viatestdb.Start). -
Step 5: commit.
git add internal/platform/imports/archive.go internal/platform/imports/archive_test.gogit commit -m "imports: archive settled debit notes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 26: EX-9 — webhooks pass a debit note through unchanged (test only)
Section titled “Task 26: EX-9 — webhooks pass a debit note through unchanged (test only)”Files:
- Modify
internal/platform/webhooks/recorder_test.go(append test and a local helper; add importsreceivables,receivablestest). - No production edit:
catalog.goL86-95 mapsreceivables.EventDocumentIssuedtosales_document.issuedwithsalesDocument(ctx, tx, e.DocumentID, e.Kind)(L154), so the resource kind is whatever the document’s kind is.api.WebhookResource.kindis a free string (api/openapi.yamlL15491), not an enum, so no spec change.
Interfaces: consumes receivablestest.New, e.Env (the embedded *expensestest.Env), endpoint(t, e, url, status, types...) (recorder_test.go L41), jobs.NewInserter, webhooks.Recorder, receivables.KindDebitNote, e.UseReceivablesPoster.
- Step 1: test.
// The catalogue is kind-agnostic: issuing a debit note announces sales_document.issued// with its own kind, with no catalogue change.func TestADebitNoteIssuedIsAnnouncedAsASalesDocument(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) ins, err := jobs.NewInserter(e.DB.Pool()) require.NoError(t, err) e.DB.Bus().SetRecorder(&webhooks.Recorder{Jobs: ins, Clock: e.Clock}) endpoint(t, e.Env, "https://one.example.com/h", "active", "sales_document.issued")
cu := e.Customer(t, "C1") inv := e.Issue(t, e.Invoice(t, cu, e.StdLine(t, "1", "100.00"))) var dn receivables.Document reason := "Freight undercharged" require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error dn, err = receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{ Kind: receivables.KindDebitNote, CustomerID: cu.PartyID, OriginalInvoiceID: &inv.ID, Reason: &reason, Currency: "MYR", Lines: []receivables.LineInput{e.StdLine(t, "1", "20.00")}}) return err })) e.Issue(t, dn)
kinds := map[string]string{} // number -> resource kind rows, err := e.H.Admin.Query(context.Background(), `SELECT payload->>'number', payload->'resource'->>'kind' FROM webhook_event WHERE org_id = $1 AND type = 'sales_document.issued'`, e.Org) require.NoError(t, err) for rows.Next() { var number, kind string require.NoError(t, rows.Scan(&number, &kind)) kinds[number] = kind } require.NoError(t, rows.Err()) require.Equal(t, map[string]string{*inv.Reference: "sales_invoice", *dn.Reference: "customer_debit_note"}, kinds, "presence: the invoice's event is there; the debit note's carries its own kind")}-
Step 2: run.
go test ./internal/platform/webhooks/ -run TestADebitNoteIssuedIsAnnounced— expected: passes once the AR section lands (it fails earlier only becauseKindDebitNote/the graph is missing). Ifwebhook_event.payloadstores the data under another key, adjust the two JSON paths to the shape asserted at recorder_test.go L90-99 (it unmarshalspayloaddirectly into the data struct, sonumberandresource.kindare top-level). -
Step 3: commit.
git add internal/platform/webhooks/recorder_test.gogit commit -m "webhooks: pin that a debit note passes through as a sales document
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 27: EX-10 — cmd/worker links the debit-note rules and guards
Section titled “Task 27: EX-10 — cmd/worker links the debit-note rules and guards”Files:
- Modify
cmd/worker/wiring_test.goL160 (AP rule lookup), L185 (guard kinds), L209 (AR rule lookup).
Interfaces: consumes the AR/AP Rules() entries (accrual rule for each debit-note kind) and EX-2/EX-3’s workflow.RegisterGuard registrations.
-
Step 1: failing test edits. L160:
{"purchase_invoice", posting.Accrual}, {"supplier_credit_note", posting.Accrual}, {"supplier_debit_note", posting.Accrual}, {"supplier_payment", posting.Payout},L185:for _, kind := range []string{"sales_invoice", "customer_credit_note", "customer_debit_note", "purchase_invoice", "supplier_credit_note", "supplier_debit_note"} {L209:{"sales_invoice", posting.Accrual}, {"customer_credit_note", posting.Accrual}, {"customer_debit_note", posting.Accrual}, {"customer_receipt", posting.Payout},(Kinds stay literals on purpose, per the comment at L180-181.) -
Step 2: watch it fail once. Temporarily revert EX-2’s
guard.goL16 edit (dropreceivables.KindDebitNote):go test ./cmd/worker/ -run TestWorkerRegistersTheEInvoiceGuardsmust printno e-Invoice guard for customer_debit_note: cmd/worker must link internal/platform/einvoice. Restore. -
Step 3: run.
go test ./cmd/worker/ -run 'TestWorkerRegistersTheEInvoiceGuards|TestWorkerLinksThePayablesModule|TestWorkerLinksTheReceivablesModule'passes. -
Step 4: commit.
git add cmd/worker/wiring_test.gogit commit -m "worker: wiring tests cover the debit-note kinds
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 28: EX-11 — record the unvalidated types as an open item
Section titled “Task 28: EX-11 — record the unvalidated types as an open item”Files:
- Modify
docs/ROADMAP.md: insert after item 11 (L606-608, “Credit note for a pre-mandate invoice (MyInvois)”) as item11a.. (The file has uncommitted edits in the working tree;git add -ponly these hunks, or add after the owner’s other edits are committed.) - Modify
internal/platform/countrypack/my/myinvois/testdata/README.md: append a paragraph.
Interfaces: none (docs). Items on the roadmap are ticked by the adviser, not by code.
- Step 1: ROADMAP text (match the neighbouring items’ style):
11a. **Debit notes 03 and 13 (MyInvois)** — UNVERIFIED. The adviser sandbox-validates one customer debit note (type `03`) with a `BillingReference` to a valid original and one without, and one self-billed supplier debit note (type `13`), against LHDN's current guideline: whether `03`/`13` take `BillingReference` at all, and that a debit note with an original waits for the original's UUID as a credit note does (D12). Until then: they are encoded as coded, the golden files `debit_note.json` and `self_billed_debit_note.json` are the encoder's own output, and an organization that cannot wait issues an extra charge as a new invoice (the documented workaround).- Step 2: README paragraph (append to
testdata/README.md):
`debit_note.json` (type 03) and `self_billed_debit_note.json` (type 13) are the encoder's own outputlike the rest, and are **not** validated against LHDN's sandbox or SDK samples: whether these typescarry `BillingReference` is unconfirmed. See ROADMAP open item 11a.-
Step 3: verify.
grep -n "11a\." docs/ROADMAP.mdprints one line;grep -c "ROADMAP open item 11a" internal/platform/countrypack/my/myinvois/testdata/README.mdprints 1. No test. -
Step 4: commit.
git add -p docs/ROADMAP.mdgit add internal/platform/countrypack/my/myinvois/testdata/README.mdgit commit -m "Roadmap: debit note types 03 and 13 await sandbox validation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Before merge (once, not per task)
Section titled “Before merge (once, not per task)”go test -p 1 ./internal/platform/einvoice/... ./internal/platform/countrypack/... ./internal/platform/taxreturn/... ./internal/platform/printing/... ./internal/platform/imports/... ./internal/platform/webhooks/... ./internal/modules/receivables/... ./cmd/worker/... then the full go test -p 1 ./... once, in a unique ERP_STACK/ERP_PG_PORT, per the repo notes.
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Out of scope here (owned by other sections, listed so nothing is assumed done)
Section titled “Out of scope here (owned by other sections, listed so nothing is assumed done)”api/openapi.yaml enums (EInvoiceSubmission.document_type gains "03", "13"; source_kind; TaxReturnLine.source_kind; ArchivedDocument.kind) and make generate belong to the API section; the e-Invoice web labels for 03/13 and the imports template columns belong to the web section. Note internal/gen/api/api.gen.go L2119 already generates EInvoiceSubmissionDocumentType constants from that enum, so the API task must run before any handler maps a "03" row.
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Debit notes: WEB plan
Section titled “Debit notes: WEB plan”Spec: docs/superpowers/specs/2026-10-05-debit-notes-design.md (section “Web”). Work in web/.
Preconditions (from the API plan, not done here): api/openapi.yaml enums extended and
web/src/lib/api/schema.d.ts regenerated, so SalesDocument['kind'] includes customer_debit_note
and PurchaseInvoice['kind'] / PurchaseInvoiceInput['kind'] include supplier_debit_note.
Assumptions to confirm with the API plan (flagged, not invented silently):
- AP supplier debit notes have NO reason field (
purchase_invoicehas no reason column; the API plan does not add one). Only the AR customer debit note carries an optional reason. GET /receivables/invoicesandGET /payables/invoicesaccept ONEkindper call (spec: “the three inlinekindquery parameters” are enums). So the open-document pickers ask once per kind.GET /receivables/matchable-linesandGET /payables/matchable-linesare not extended (spec: debit notes are not delivery/GRN matched in v1), so the dialogs must not call them for a debit note.- No web change is needed for e-Invoice (
web/src/features/einvoicerenders no document type or kind text;source_kindstrings appear only on the accounting entry drawer as raw<code>), printing (Go templates), or webhooks.imports.jsonsummary.credit_notesis a count label fed by the Go import summary; add adebit_noteslabel only if the imports plan adds that count. - Not touched:
web/src/app/shell/*,web/e2e/seed.ts,web/e2e/README.md,web/e2e/sample/*,web/src/lib/i18n/locales/*/shell.json(the user’s uncommitted edits).receivables.jsonandpayables.jsoninvoices.subtitle(“… and credit notes …”) are left as is.
Conventions used below:
- Vitest command:
cd /Users/limziyang/Documents/repos/mcomtask/web && npx vitest run <file>(scripttest=vitest run,web/package.json). - Every task that adds i18n keys edits en, ms and zh in the same commit (the parity test
web/src/drift/i18nparity.test.tsfails on a lone locale). - Task order matters: WEB-1 before WEB-2/3, WEB-5 before WEB-6; WEB-4 and WEB-7 depend on WEB-4’s helper (created in WEB-4) only for the test files they edit (WEB-7 uses it).
- Commit trailer on every commit:
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>.
Task 29: WEB-1 — AR route search enum, third tab, receivables tab/title keys, key-presence test
Section titled “Task 29: WEB-1 — AR route search enum, third tab, receivables tab/title keys, key-presence test”Files:
- Modify
web/src/features/receivables/routes.tsx:78-80(invoiceSearchenum) - Modify
web/src/features/receivables/InvoicesPage.tsx:114(tab array) - Modify
web/src/lib/i18n/locales/en/receivables.json:75-77,ms/receivables.json:78-80,zh/receivables.json:75-77 - Create
web/src/lib/i18n/debitNoteKeys.test.ts - Test:
web/src/features/receivables/__tests__/InvoicesPage.test.tsx(pattern at lines 94-103)
Interfaces: DocumentKind (web/src/features/receivables/api.ts:14, = SalesDocument['kind']) now includes
'customer_debit_note'. InvoicesPage props kind/onKind unchanged (InvoicesPage.tsx:18-19).
The kind search param is z.enum([...]).catch('sales_invoice'), so an unlisted kind silently falls back to
invoices: that is why this task must land first.
- Step 1: failing page test. Add inside
describe('InvoicesPage', ...)inweb/src/features/receivables/__tests__/InvoicesPage.test.tsx, after the “switches to credit notes by tab” test:
it('lists debit notes under a third tab and offers "New debit note"', async () => { const kinds: (string | null)[] = []; mockApi(); server.use( http.get('*/v1/receivables/invoices', ({ request }) => { const kind = new URL(request.url).searchParams.get('kind'); kinds.push(kind); return HttpResponse.json({ documents: kind === 'customer_debit_note' ? [ doc({ id: 'dn1', kind: 'customer_debit_note', reference: 'DN-2026-0001', state: 'issued', semantic: 'accrual', }), ] : [doc()], next_cursor: null, }); }), ); const { user } = await renderWithApp({ path: '/receivables/invoices', permissions: ['receivables.invoice.read', 'receivables.invoice.create'], }); await screen.findByText('Draft'); await user.click(screen.getByRole('tab', { name: 'Debit notes' })); expect(await screen.findByText('DN-2026-0001')).toBeInTheDocument(); expect(kinds).toContain('customer_debit_note'); expect(screen.getByRole('button', { name: 'New debit note' })).toBeInTheDocument(); });Create web/src/lib/i18n/debitNoteKeys.test.ts (parity only proves the three locales agree; this proves the
debit-note keys exist at all, in all three):
import { readFileSync } from 'node:fs';import { join } from 'node:path';import { describe, expect, it } from 'vitest';import { WEB_ROOT } from '../../drift/scan';
const LOCALES = ['en', 'ms', 'zh'] as const;
/** [namespace, key path] for every string the debit-note screens need. */const PATHS: [string, string[]][] = [ // control: a credit-note key that already ships, so a broken reader cannot pass vacuously ['receivables', ['invoices', 'tabs', 'customer_credit_note']], ['receivables', ['invoices', 'tabs', 'customer_debit_note']], ['receivables', ['invoices', 'new', 'customer_debit_note']], ['receivables', ['invoices', 'detailTitle', 'customer_debit_note']],];
function at(locale: string, ns: string, path: string[]): unknown { const file = join(WEB_ROOT, 'src/lib/i18n/locales', locale, `${ns}.json`); return path.reduce<unknown>((o, k) => (o as Record<string, unknown> | undefined)?.[k], JSON.parse(readFileSync(file, 'utf8')));}
describe('debit-note strings', () => { it.each(LOCALES)('%s has every debit-note key, non-empty', (locale) => { expect(at(locale, 'receivables', ['invoices', 'tabs', 'customer_credit_note'])).toBeTruthy(); const missing = PATHS.filter(([ns, p]) => { const v = at(locale, ns, p); return typeof v !== 'string' || v.trim() === ''; }).map(([ns, p]) => `${ns}:${p.join('.')}`); expect(missing).toEqual([]); });});-
Step 2: run, see red.
cd /Users/limziyang/Documents/repos/mcomtask/web && npx vitest run src/features/receivables/__tests__/InvoicesPage.test.tsx src/lib/i18n/debitNoteKeys.test.tsExpected: page test fails withUnable to find an accessible element with the role "tab" and name "Debit notes"; key test fails per locale with["receivables:invoices.tabs.customer_debit_note", ...]. -
Step 3: implement.
routes.tsx:79becomes:kind: z.enum(['sales_invoice', 'customer_credit_note', 'customer_debit_note']).catch('sales_invoice'),InvoicesPage.tsx:114becomes:items={(['sales_invoice', 'customer_credit_note', 'customer_debit_note'] as const).map((k) => ({en/receivables.jsonlines 75-77 become:
"tabs": { "sales_invoice": "Invoices", "customer_credit_note": "Credit notes", "customer_debit_note": "Debit notes" }, "new": { "sales_invoice": "New invoice", "customer_credit_note": "New credit note", "customer_debit_note": "New debit note" }, "detailTitle": { "sales_invoice": "Sales invoice", "customer_credit_note": "Credit note", "customer_debit_note": "Debit note" },-
ms/receivables.jsonlines 78-80 become the same shape with values"Invois","Nota kredit","Nota debit";"Invois baharu","Nota kredit baharu","Nota debit baharu";"Invois jualan","Nota kredit","Nota debit". -
zh/receivables.jsonlines 75-77 become the same shape with values"发票","贷项通知单","借项通知单";"新建发票","新建贷项通知单","新建借项通知单";"销售发票","贷项通知单","借项通知单". -
Run
npx prettier --write src/lib/i18n/debitNoteKeys.test.ts(theathelper line exceeds the print width). -
Step 4: run, see green.
npx vitest run src/features/receivables/__tests__/InvoicesPage.test.tsx src/lib/i18n/debitNoteKeys.test.ts src/drift/i18nparity.test.tsExpected: all pass. -
Step 5: commit.
git add web/src/features/receivables/routes.tsx web/src/features/receivables/InvoicesPage.tsx web/src/features/receivables/__tests__/InvoicesPage.test.tsx web/src/lib/i18n/locales/en/receivables.json web/src/lib/i18n/locales/ms/receivables.json web/src/lib/i18n/locales/zh/receivables.json web/src/lib/i18n/debitNoteKeys.test.tsgit commit -m "Web: customer debit notes tab on the receivables invoices page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 30: WEB-2 — AR InvoiceDialog debit-note branch
Section titled “Task 30: WEB-2 — AR InvoiceDialog debit-note branch”Files:
- Modify
web/src/features/receivables/InvoiceDialog.tsx: constant at line 45;isCNat line 71;useMatchableSalesLinesat line 114;useIssuedInvoicesat 116-118;pickOriginalat 142-150; header ternary at 230-297 - Modify
web/src/lib/i18n/locales/{en,ms,zh}/receivables.json(invoices.fields, en lines 92-100, ms ~95-103, zh ~89-97) - Modify
web/src/lib/i18n/debitNoteKeys.test.ts(extendPATHS) - Test:
web/src/features/receivables/__tests__/InvoiceDialog.test.tsx(pattern at 149-185;mockApiat 61-105)
Interfaces: InvoiceDialog({ kind: DocumentKind, view?, onClose, onDone }) unchanged. Behaviour for
kind === 'customer_debit_note': follows the invoice branch (currency, rate, terms shown; customer defaults applied;
button label invoices.issue “Issue invoice”; the submit edge is the preparer’s reporter gate, same as an invoice),
plus an optional original-invoice picker (issued invoices of the customer, via useIssuedInvoices, with a “no original”
option) and an optional reason. Picking an original does NOT copy its currency or rate (CN does, pickOriginal 142-150).
No delivery-line matching.
- Step 1: failing test. Add in
describe('InvoiceDialog', ...)after the credit-note test (line 185):
it('lets a debit note pick an optional original and reason, keeps its own currency, and issues directly', async () => { const posted: Record<string, unknown>[] = []; const transitions: unknown[] = []; mockApi(transitions); server.use( http.get('*/v1/receivables/invoices', () => HttpResponse.json({ documents: [ invoice({ id: 'orig1', reference: 'INV-2026-0002', currency: 'EUR', fx_rate: '5.0000000000', gross_amount: '108.0000', semantic: 'accrual', state: 'issued', }), ], next_cursor: null, }), ), http.post('*/v1/receivables/invoices', async ({ request }) => { posted.push((await request.json()) as Record<string, unknown>); return HttpResponse.json(invoice({ kind: 'customer_debit_note' }), { status: 201 }); }), ); const { user } = await renderWithApp({ path: '/receivables/invoices/new?kind=customer_debit_note', permissions: [ 'receivables.invoice.read', 'receivables.invoice.create', 'accounting.ledger.read', ], }); await user.click(await screen.findByRole('combobox', { name: 'Customer' })); await user.click(await screen.findByRole('option', { name: /US Customer Inc/ })); // PRESENCE: an invoice's own fields are there on a debit note. expect((screen.getByLabelText('Currency') as HTMLInputElement).value).toBe('USD'); expect(screen.getByLabelText('Payment terms (days)')).toBeInTheDocument(); await user.click(await screen.findByRole('combobox', { name: 'Original invoice (optional)' })); await user.click(await screen.findByRole('option', { name: /INV-2026-0002/ })); // ABSENCE: the original's EUR did not overwrite the debit note's own currency. expect((screen.getByLabelText('Currency') as HTMLInputElement).value).toBe('USD'); await user.type(screen.getByLabelText('Reason for the debit'), 'Freight'); const dialog = screen.getByRole('dialog'); await user.click(within(dialog).getByRole('button', { name: 'Issue invoice' })); await waitFor(() => expect(transitions).toEqual([{ from: 'draft', to: 'issued' }])); expect(posted[0]).toMatchObject({ kind: 'customer_debit_note', original_invoice_id: 'orig1', reason: 'Freight', currency: 'USD', }); });
it('lets a debit note be saved with no original and no reason', async () => { const posted: Record<string, unknown>[] = []; mockApi(); server.use( http.post('*/v1/receivables/invoices', async ({ request }) => { posted.push((await request.json()) as Record<string, unknown>); return HttpResponse.json(invoice({ kind: 'customer_debit_note' }), { status: 201 }); }), ); const { user } = await renderWithApp({ path: '/receivables/invoices/new?kind=customer_debit_note', permissions: [ 'receivables.invoice.read', 'receivables.invoice.create', 'accounting.ledger.read', ], }); await user.click(await screen.findByRole('button', { name: 'Save draft' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ kind: 'customer_debit_note', original_invoice_id: null }); });Extend PATHS in debitNoteKeys.test.ts with:
['receivables', ['invoices', 'fields', 'originalOptional']], ['receivables', ['invoices', 'fields', 'noOriginal']], ['receivables', ['invoices', 'fields', 'reasonDebit']],-
Step 2: run, see red.
npx vitest run src/features/receivables/__tests__/InvoiceDialog.test.tsx src/lib/i18n/debitNoteKeys.test.tsExpected: first new test failsUnable to find role="combobox" and name "Original invoice (optional)"; the second may already pass once WEB-1 landed (it is the presence guard for “no original” staying null); key test fails on the three keys. -
Step 3: implement. In
InvoiceDialog.tsx:- after line 45 (
const UNLINKED = '__unlinked';) addconst NO_ORIGINAL = '__no_original'; - replace line 71 with:
- after line 45 (
const isCN = kind === 'customer_credit_note'; const isDN = kind === 'customer_debit_note'; const wantsOriginal = isCN || isDN; // a credit note must name one; a debit note may- line 114:
const deliveries = useMatchableSalesLines(isDN ? '' : h.customer_id, kind); // debit notes are not delivery-matched (disabled on an empty id) - lines 116-118:
customer_id: wantsOriginal ? h.customer_id : undefined, - replace
pickOriginal(142-150) with:
function pickOriginal(id: string) { // A debit note carries its own currency and rate; only a credit note inherits the original's. if (isDN) return setH((s) => ({ ...s, original_invoice_id: id === NO_ORIGINAL ? null : id })); const o = issuedInvoices.find((x) => x.id === id); setH((s) => ({ ...s, original_invoice_id: id, currency: o?.currency ?? s.currency, fx_rate: o?.fx_rate ?? s.fx_rate, })); }- header block, replace lines 230-231 (
{isCN ? (/<>) with{wantsOriginal && (/<>; in that block use these labels and options:
<Field label={t(isDN ? 'invoices.fields.originalOptional' : 'invoices.fields.original')} id="si-orig" > <Select id="si-orig" aria-label={t(isDN ? 'invoices.fields.originalOptional' : 'invoices.fields.original')} value={h.original_invoice_id ?? undefined} onValueChange={(x: string) => pickOriginal(x)} options={[ ...(isDN ? [{ value: NO_ORIGINAL, label: t('invoices.fields.noOriginal') }] : []), ...issuedInvoices.map((o) => ({ value: o.id, label: `${o.reference} · ${o.currency} ${o.gross_amount}`, })), ]} /> </Field> <div className="md:col-span-2"> <Field label={t(isDN ? 'invoices.fields.reasonDebit' : 'invoices.fields.reason')} id="si-reason" > <Input id="si-reason" value={h.reason ?? ''} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, reason: isDN ? e.target.value || null : e.target.value })) } /> </Field> </div>and replace lines 255-257 (`</>` / `) : (` / `<>`) with `</>` / `)}` / `{!isCN && (` / `<>` (the invoice fields block at 257-297 is otherwise unchanged and still closes with `</>` `)}`).-
i18n
invoices.fieldsadditions (after"reason"; add a comma to thereasonline):- en:
"originalOptional": "Original invoice (optional)","noOriginal": "No original invoice","reasonDebit": "Reason for the debit" - ms:
"originalOptional": "Invois asal (pilihan)","noOriginal": "Tiada invois asal","reasonDebit": "Sebab debit" - zh:
"originalOptional": "原发票(可选)","noOriginal": "无原发票","reasonDebit": "借记原因"
- en:
-
Step 4: run, see green.
npx vitest run src/features/receivables/__tests__/InvoiceDialog.test.tsx src/lib/i18n/debitNoteKeys.test.ts src/drift/i18nparity.test.tsExpected: all pass, including the existing credit-note tests (labels “Invoice being corrected”, “Reason for the credit” and the absent Currency field are unchanged forcustomer_credit_note). -
Step 5: commit.
git add web/src/features/receivables/InvoiceDialog.tsx web/src/features/receivables/__tests__/InvoiceDialog.test.tsx web/src/lib/i18n/locales/en/receivables.json web/src/lib/i18n/locales/ms/receivables.json web/src/lib/i18n/locales/zh/receivables.json web/src/lib/i18n/debitNoteKeys.test.tsgit commit -m "Web: customer debit note dialog (optional original and reason, own currency)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 31: WEB-3 — AR InvoiceDrawer shows a debit note’s title and reason
Section titled “Task 31: WEB-3 — AR InvoiceDrawer shows a debit note’s title and reason”Files:
- Modify
web/src/features/receivables/InvoiceDrawer.tsx(title already keyed at line 88 byinvoices.detailTitle.${v.kind}, added in WEB-1; insert reason paragraph after the date block ending at line 114) - Create
web/src/features/receivables/__tests__/InvoiceDrawer.test.tsx
Interfaces: isCN (line 50) stays credit-note only, so the offset form (lines 230-266) and the useOpenInvoices
customer filter (lines 52-55) are not shown for a debit note: it is a target of allocations, never a source. The drawer
reads SalesDocumentView.reason (exists, InvoiceDialog.tsx:85).
- Step 1: failing test. Create
web/src/features/receivables/__tests__/InvoiceDrawer.test.tsx:
import { describe, expect, it } from 'vitest';import { screen } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const view = (over: Record<string, unknown> = {}) => ({ id: 'dn1', kind: 'customer_debit_note', reference: 'DN-2026-0001', customer_id: 'c1', original_invoice_id: null, reason: 'Freight', customer_reference: '', currency: 'MYR', fx_rate: null, payment_terms_days: 30, net_total: '50.0000', tax_total: '4.0000', gross_amount: '54.0000', gross_base: '54.0000', issued_at: '2026-09-21T01:00:00Z', invoice_date: '2026-09-21', due_on: '2026-10-21', seller: { name: 'Demo Sdn Bhd' }, buyer: { name: 'US Customer Inc' }, state: 'issued', semantic: 'accrual', prepared_by: 'm1', revision: 2, created_at: '2026-09-21T01:00:00Z', lines: [], allocations: [], outstanding: '54.0000', outstanding_base: '54.0000', available_transitions: [], timeline: [], ...over,});
function mockApi(over: Record<string, unknown> = {}) { server.use( http.get('*/v1/workflow/definitions/:kind', () => HttpResponse.json({ kind: 'customer_debit_note', version: 1, graph: { kind: 'customer_debit_note', posting_order: 'accrual_first', edges: [], grants: [], states: [ { key: 'issued', semantic: 'accrual', display_order: 1, is_terminal: false, labels: { en: 'Issued' }, }, ], }, }), ), http.get('*/v1/receivables/customers', () => HttpResponse.json({ customers: [] })), http.get('*/v1/receivables/invoices', () => HttpResponse.json({ documents: [], next_cursor: null }), ), http.get('*/v1/receivables/invoices/:id', () => HttpResponse.json(view(over))), http.get('*/v1/einvoice/submissions', () => HttpResponse.json({ submissions: [], next_cursor: null }), ), );}
describe('InvoiceDrawer for a debit note', () => { it('is titled "Debit note", shows its reason, and offers no offset form', async () => { mockApi(); await renderWithApp({ path: '/receivables/invoices/dn1?kind=customer_debit_note', permissions: ['receivables.invoice.read', 'receivables.invoice.create'], }); expect(await screen.findByRole('dialog', { name: 'Debit note' })).toBeInTheDocument(); expect(screen.queryByRole('combobox', { name: 'Invoice' })).not.toBeInTheDocument(); });
it('shows the offset form for a credit note (presence for the absence above)', async () => { mockApi({ kind: 'customer_credit_note', reason: 'Return' }); await renderWithApp({ path: '/receivables/invoices/dn1?kind=customer_credit_note', permissions: ['receivables.invoice.read', 'receivables.invoice.create'], }); expect(await screen.findByRole('combobox', { name: 'Invoice' })).toBeInTheDocument(); });});-
Step 2: run, see red.
npx vitest run src/features/receivables/__tests__/InvoiceDrawer.test.tsxExpected: first test failsUnable to find an element with the text: /Reason for the debit/(title assertion passes after WEB-1; if the drawer is not exposed as roledialognamed by its title, change the first assertion tofindByText('Debit note')and note it). Second test passes (it is the presence guard). -
Step 3: implement. In
InvoiceDrawer.tsx, directly after the closing)}of the{v.invoice_date && (...)}block (line 114), insert:
{v.kind === 'customer_debit_note' && v.reason && ( <p className="text-caption text-fg-muted"> {t('invoices.fields.reasonDebit')}: {v.reason} </p> )}-
Step 4: run, see green.
npx vitest run src/features/receivables/__tests__/InvoiceDrawer.test.tsx -
Step 5: commit.
git add web/src/features/receivables/InvoiceDrawer.tsx web/src/features/receivables/__tests__/InvoiceDrawer.test.tsxgit commit -m "Web: customer debit note drawer shows its reason
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 32: WEB-4 — AR receipt and credit-note-offset pickers include open debit notes
Section titled “Task 32: WEB-4 — AR receipt and credit-note-offset pickers include open debit notes”Files:
- Modify
web/src/features/receivables/api.ts:115-136(useOpenInvoices); the comment at lines 112-114 - Create
web/src/test/msw/forKind.ts - Modify
web/src/features/receivables/__tests__/ReceiptDialog.test.tsx:83-85(existing handler) and add one test (pattern at 94-120)
Interfaces: useOpenInvoices(f: { customer_id?: string; currency?: string }): UseQueryResult<SalesDocument[]>
unchanged signature. It now pages through kind: 'sales_invoice' then kind: 'customer_debit_note' (open: true) and
concatenates, because the endpoint takes one kind per call. Consumers: ReceiptDialog.tsx:46, ReceiptDrawer.tsx:40,
InvoiceDrawer.tsx:52 (credit-note offset targets). useIssuedInvoices (line 141) is NOT changed: a debit note’s
original, like a credit note’s, is a sales invoice. Rendering needs no change: rows are keyed by i.id, labelled by
i.reference (ReceiptDialog.tsx:209).
- Step 1: failing test + test helper. Create
web/src/test/msw/forKind.ts:
import { HttpResponse } from 'msw';
/** * The open-document pickers ask once per payable kind. A fixture written for one kind answers every * other kind with an empty page, so a document is never listed twice. */export function forKind( request: Request, kind: string, list: 'invoices' | 'documents', body: Record<string, unknown>,) { const asked = new URL(request.url).searchParams.get('kind'); return HttpResponse.json(asked === kind || asked === null ? body : { [list]: [], next_cursor: null });}In ReceiptDialog.test.tsx add import { forKind } from '@/test/msw/forKind'; and replace lines 83-85:
http.get('*/v1/receivables/invoices', ({ request }) => forKind(request, 'sales_invoice', 'documents', { documents: opts.invoices ?? [], next_cursor: null, }), ),Add this test after “lists the customer’s open invoices…” (it reuses mockApi, openInvoice, customer):
it('also offers the customer\'s open debit notes, asking for each kind (presence first)', async () => { const asked: string[] = []; mockApi({ invoices: [openInvoice('i1', 'INV-2026-0001')] }); server.use( http.get('*/v1/receivables/invoices', ({ request }) => { const kind = new URL(request.url).searchParams.get('kind') ?? ''; asked.push(kind); return HttpResponse.json({ documents: kind === 'customer_debit_note' ? [{ ...openInvoice('d1', 'DN-2026-0001'), kind: 'customer_debit_note' }] : [openInvoice('i1', 'INV-2026-0001')], next_cursor: null, }); }), ); const { user } = await renderWithApp({ path: '/receivables/receipts/new', permissions: ['receivables.receipt.read', 'receivables.receipt.create'], }); const dialog = await screen.findByRole('dialog'); await user.click(within(dialog).getByRole('combobox', { name: 'Customer' })); await user.click(await screen.findByRole('option', { name: /Kedai Runcit Sdn Bhd/ })); expect(await within(dialog).findByLabelText('Apply INV-2026-0001')).toBeInTheDocument(); expect(await within(dialog).findByLabelText('Apply DN-2026-0001')).toBeInTheDocument(); expect(asked).toEqual(['sales_invoice', 'customer_debit_note']); });(If the existing customer fixture’s option text differs, copy the exact regexp used by the test at lines 94-120.)
-
Step 2: run, see red.
npx vitest run src/features/receivables/__tests__/ReceiptDialog.test.tsxExpected: new test failsUnable to find a label with the text of: Apply DN-2026-0001(andaskedwould be only['sales_invoice']); existing tests still pass. -
Step 3: implement. In
api.tsreplaceuseOpenInvoices(lines 112-136) with:
// Every page of every issued sales invoice and debit note of a customer with something// outstanding: a picker that showed only the first page would hide documents that can still be// paid or offset (mirrors payables' useOpenInvoices). A debit note raises what the customer owes,// so it is as payable as an invoice; the endpoint takes one kind per call, so ask once per kind.export function useOpenInvoices(f: { customer_id?: string; currency?: string }) { return useQuery({ queryKey: receivablesKeys.documents({ ...f, kind: 'sales_invoice+debit_note', open: true }), enabled: !!f.customer_id, queryFn: async ({ signal }) => { const out: SalesDocument[] = []; for (const kind of ['sales_invoice', 'customer_debit_note'] as const) { let cursor: string | undefined; do { const page = unwrap( await api.GET('/receivables/invoices', { params: { query: { ...f, kind, open: true, limit: 200, cursor } }, signal, }), ); out.push(...page.documents); cursor = page.next_cursor ?? undefined; } while (cursor); } return out; }, });}(The query key’s kind is a cache label only; it is not sent. It keeps this list from colliding with
useDocuments’ {kind:'sales_invoice'} entries under ['receivables','documents', ...], which
useAfterDocument invalidates by prefix at api.ts:~173.)
-
Step 4: run, see green.
npx vitest run src/features/receivablesExpected: all receivables tests pass. -
Step 5: commit.
git add web/src/features/receivables/api.ts web/src/features/receivables/__tests__/ReceiptDialog.test.tsx web/src/test/msw/forKind.tsgit commit -m "Web: open debit notes are receivable targets in the receipt and offset pickers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 33: WEB-5 — AP route search enum, third tab, drawer title, payables keys
Section titled “Task 33: WEB-5 — AP route search enum, third tab, drawer title, payables keys”Files:
- Modify
web/src/features/payables/routes.tsx:86-88 - Modify
web/src/features/payables/InvoicesPage.tsx:116 - Modify
web/src/lib/i18n/locales/en/payables.json:94-99,ms/payables.json:98-111,zh/payables.json:94-96 - Modify
web/src/lib/i18n/debitNoteKeys.test.ts(extendPATHS) - Test:
web/src/features/payables/__tests__/InvoicesPage.test.tsx(tab test at 301-310;mockApiat 87-155)
Interfaces: InvoiceKind (web/src/features/payables/api.ts:20, = PurchaseInvoice['kind']) now includes
'supplier_debit_note'. isCN (InvoiceDrawer.tsx:54) stays credit-note only, so no offset form on a debit note.
The AP drawer needs no code change: the title is already keyed by invoices.detailTitle.${inv.kind} (InvoiceDrawer.tsx:73).
- Step 1: failing tests. In
InvoicesPage.test.tsx, after “switches to credit notes by tab” (line 310):
it('lists debit notes under a third tab and offers "New debit note"', async () => { const kinds: (string | null)[] = []; mockApi(); server.use( http.get('*/v1/payables/invoices', ({ request }) => { const kind = new URL(request.url).searchParams.get('kind'); kinds.push(kind); return HttpResponse.json({ invoices: kind === 'supplier_debit_note' ? [invoice({ id: 'dn1', kind: 'supplier_debit_note', reference: 'SDN-2026-0001' })] : [invoice()], next_cursor: null, }); }), ); const { user } = await renderWithApp({ path: '/payables/invoices', permissions: ['payables.invoice.read', 'payables.invoice.create'], }); await screen.findByText('PI-2026-0001'); await user.click(screen.getByRole('tab', { name: 'Debit notes' })); expect(await screen.findByText('SDN-2026-0001')).toBeInTheDocument(); expect(kinds).toContain('supplier_debit_note'); expect(screen.getByRole('button', { name: 'New debit note' })).toBeInTheDocument(); });
it('titles a supplier debit note drawer', async () => { mockApi(); server.use( http.get('*/v1/payables/invoices/:id', () => HttpResponse.json({ invoice: invoice({ id: 'dn1', kind: 'supplier_debit_note', reference: 'SDN-2026-0001', }), lines: [], allocations: [], outstanding: '100.0000', outstanding_base: '440.0000', decisions: [], available_transitions: [], }), ), ); await renderWithApp({ path: '/payables/invoices/dn1?kind=supplier_debit_note', permissions: ['payables.invoice.read'], }); expect(await screen.findByRole('dialog', { name: 'Supplier debit note' })).toBeInTheDocument(); });Extend PATHS in debitNoteKeys.test.ts:
['payables', ['invoices', 'tabs', 'supplier_credit_note']], // control ['payables', ['invoices', 'tabs', 'supplier_debit_note']], ['payables', ['invoices', 'new', 'supplier_debit_note']], ['payables', ['invoices', 'detailTitle', 'supplier_debit_note']], ['payables', ['invoices', 'fields', 'originalDebit']], ['payables', ['invoices', 'fields', 'noOriginal']],(originalDebit and noOriginal are used by WEB-6; adding them here keeps the payables JSON edit in one commit.)
-
Step 2: run, see red.
npx vitest run src/features/payables/__tests__/InvoicesPage.test.tsx src/lib/i18n/debitNoteKeys.test.tsExpected:Unable to find an accessible element with the role "tab" and name "Debit notes"; the drawer test fails (title falls back to the raw key text); key test lists the seven payables paths. -
Step 3: implement.
routes.tsx:87:kind: z.enum(['purchase_invoice', 'supplier_credit_note', 'supplier_debit_note']).catch('purchase_invoice'),InvoicesPage.tsx:116:items={(['purchase_invoice', 'supplier_credit_note', 'supplier_debit_note'] as const).map((k) => ({en/payables.json: replace lines 94-99 with
"tabs": { "purchase_invoice": "Invoices", "supplier_credit_note": "Credit notes", "supplier_debit_note": "Debit notes" }, "new": { "purchase_invoice": "New invoice", "supplier_credit_note": "New credit note", "supplier_debit_note": "New debit note" }, "detailTitle": { "purchase_invoice": "Purchase invoice", "supplier_credit_note": "Supplier credit note", "supplier_debit_note": "Supplier debit note" },and in `fields` after `"originalInvoice": "Original self-billed invoice"` add (comma after it):`"originalDebit": "Original invoice (optional)"`, `"noOriginal": "No original invoice"`.-
ms/payables.json: the existing tabs/new/detailTitle blocks (98-111) gain"supplier_debit_note"entries"Nota debit","Nota debit baharu","Nota debit pembekal";fieldsgains (afteroriginalInvoice, line 139)"originalDebit": "Invois asal (pilihan)","noOriginal": "Tiada invois asal". -
zh/payables.json: lines 94-96 gain"supplier_debit_note"entries"借项通知单","新建借项通知单","供应商借项通知单"(keep the single-line shape, prettier will reflow);fieldsgains (after line 121)"originalDebit": "原发票(可选)","noOriginal": "无原发票". -
Run
npx prettier --writeon the three payables JSON files anddebitNoteKeys.test.ts. -
Step 4: run, see green.
npx vitest run src/features/payables/__tests__/InvoicesPage.test.tsx src/lib/i18n/debitNoteKeys.test.ts src/drift/i18nparity.test.ts -
Step 5: commit.
git add web/src/features/payables/routes.tsx web/src/features/payables/InvoicesPage.tsx web/src/features/payables/__tests__/InvoicesPage.test.tsx web/src/lib/i18n/locales/en/payables.json web/src/lib/i18n/locales/ms/payables.json web/src/lib/i18n/locales/zh/payables.json web/src/lib/i18n/debitNoteKeys.test.tsgit commit -m "Web: supplier debit notes tab, drawer title and strings on payables invoices
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 34: WEB-6 — AP InvoiceDialog debit-note branch
Section titled “Task 34: WEB-6 — AP InvoiceDialog debit-note branch”Files:
- Modify
web/src/features/payables/InvoiceDialog.tsx: header state at 78-94;useMatchableLinesat 95;originalsquery at 115-141; JSX after the self-billed original block ending at 341 - Test:
web/src/features/payables/__tests__/InvoicesPage.test.tsx(describe('InvoiceDialog self-billed (D13)'at ~348; pattern at 378-410)
Interfaces: InvoiceDialog({ kind: InvoiceKind, view?, onClose, onDone }) unchanged. The AP dialog is already the
invoice shape (supplier, number, dates, currency, document, basis), so a supplier_debit_note needs only: an optional
original-invoice picker (any approved purchase invoice of the supplier; the credit-note picker at 329-341 stays
self-billed-only) and no GRN matching (no reason field: AP has none). Submit stays invoices.submit (the graph decides the edge,
run() at 165-206 takes the preparer’s reporter edge, which for a debit note is the direct post).
- Step 1: failing test. Add in
describe('InvoiceDialog self-billed (D13)', ...)after the existing original-invoice test (ends ~line 410):
it('lets a supplier debit note pick an optional original invoice', async () => { mockApi(); server.use( http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })), http.get('*/v1/accounting/tax-codes', () => HttpResponse.json({ tax_codes: [] })), http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [{ id: 's1', code: 'USSUP', legal_name: 'US Supplier Inc', is_active: true }], }), ), ); const posted: Record<string, unknown>[] = []; server.use( http.post('*/v1/payables/invoices', async ({ request }) => { posted.push((await request.json()) as Record<string, unknown>); return HttpResponse.json({ invoice: invoice({ kind: 'supplier_debit_note' }), lines: [], allocations: [], outstanding: '100.0000', outstanding_base: '440.0000', decisions: [], available_transitions: [], }); }), ); const { user } = await renderWithApp({ path: '/payables/invoices/new?kind=supplier_debit_note', permissions: ['payables.invoice.read', 'payables.invoice.create', 'accounting.ledger.read'], }); await user.click(await screen.findByRole('combobox', { name: 'Supplier' })); await user.click(await screen.findByRole('option', { name: /US Supplier Inc/ })); // PRESENCE: no basis chosen, so the credit-note-only self-billed picker is not what shows here. expect(screen.queryByRole('combobox', { name: 'Original self-billed invoice' })).not.toBeInTheDocument(); await user.click(await screen.findByRole('combobox', { name: 'Original invoice (optional)' })); await user.click(await screen.findByRole('option', { name: /PI-2026-0001/ })); expect(screen.queryByLabelText('Reason for the debit')).not.toBeInTheDocument(); // AP has no reason await user.click(screen.getByRole('button', { name: 'Save draft' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ kind: 'supplier_debit_note', original_invoice_id: 'i1' }); });
it('asks for matchable lines on a supplier credit note (presence)', async () => { const asked: string[] = []; mockApi(); server.use( http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })), http.get('*/v1/accounting/tax-codes', () => HttpResponse.json({ tax_codes: [] })), http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [{ id: 's1', code: 'USSUP', legal_name: 'US Supplier Inc', is_active: true }], }), ), http.get('*/v1/payables/matchable-lines', ({ request }) => { asked.push(new URL(request.url).searchParams.get('kind') ?? ''); return HttpResponse.json({ lines: [] }); }), ); const { user } = await renderWithApp({ path: '/payables/invoices/new?kind=supplier_credit_note', permissions: ['payables.invoice.read', 'payables.invoice.create', 'accounting.ledger.read'], }); await user.click(await screen.findByRole('combobox', { name: 'Supplier' })); await user.click(await screen.findByRole('option', { name: /US Supplier Inc/ })); await waitFor(() => expect(asked).toEqual(['supplier_credit_note'])); // PRESENCE: a credit note does ask });The test above is the presence half; add this absence half to the same describe:
it('(absence) a supplier debit note never asks for matchable lines', async () => { const asked: string[] = []; mockApi(); server.use( http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })), http.get('*/v1/accounting/tax-codes', () => HttpResponse.json({ tax_codes: [] })), http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [{ id: 's1', code: 'USSUP', legal_name: 'US Supplier Inc', is_active: true }], }), ), http.get('*/v1/payables/matchable-lines', ({ request }) => { asked.push(new URL(request.url).searchParams.get('kind') ?? ''); return HttpResponse.json({ lines: [] }); }), ); const { user } = await renderWithApp({ path: '/payables/invoices/new?kind=supplier_debit_note', permissions: ['payables.invoice.read', 'payables.invoice.create', 'accounting.ledger.read'], }); await user.click(await screen.findByRole('combobox', { name: 'Supplier' })); await user.click(await screen.findByRole('option', { name: /US Supplier Inc/ })); await screen.findByRole('combobox', { name: 'Original invoice (optional)' }); expect(asked).toEqual([]); });-
Step 2: run, see red.
npx vitest run src/features/payables/__tests__/InvoicesPage.test.tsxExpected: first test failsUnable to find role="combobox" and name "Original invoice (optional)"; the absence test fails the same way; the credit-note presence test passes. -
Step 3: implement. In
InvoiceDialog.tsx:- line 95:
const matchable = useMatchableLines(kind === 'supplier_debit_note' ? '' : h.supplier_id, kind); // debit notes are not GRN-matched (disabled on an empty id) - line 117:
enabled: ((kind === 'supplier_credit_note' && selfBilled) || kind === 'supplier_debit_note') && !!h.supplier_id,and update the comment above it (lines 112-114) to start:// Fetched for a self-billed credit note's original, and for a debit note's optional one; ... - after the self-billed original block (line 341,
)}) insert:
- line 95:
{kind === 'supplier_debit_note' && ( <Field label={t('invoices.fields.originalDebit')} id="pi-dn-orig"> <Select id="pi-dn-orig" aria-label={t('invoices.fields.originalDebit')} value={h.original_invoice_id ?? undefined} onValueChange={(x: string) => setH((s) => ({ ...s, original_invoice_id: x === NO_ORIGINAL ? null : x })) } options={[ { value: NO_ORIGINAL, label: t('invoices.fields.noOriginal') }, ...(originals.data ?? []).map((x) => ({ value: x.id, label: `${x.reference} · ${x.supplier_invoice_no}`, })), ]} /> </Field> )}-
add near the top of the file next to the existing
UNMATCHEDconstant (grepUNMATCHED =):const NO_ORIGINAL = '__no_original'; -
Step 4: run, see green.
npx vitest run src/features/payables/__tests__/InvoicesPage.test.tsxExpected: all pass, including the existing self-billed credit-note tests (picker label “Original self-billed invoice” unchanged; thestate=approvedassertion at line ~513 still holds). -
Step 5: commit.
git add web/src/features/payables/InvoiceDialog.tsx web/src/features/payables/__tests__/InvoicesPage.test.tsxgit commit -m "Web: supplier debit note dialog (optional original, no GRN matching)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 35: WEB-7 — AP payment and credit-note-offset pickers include open debit notes
Section titled “Task 35: WEB-7 — AP payment and credit-note-offset pickers include open debit notes”Files:
- Modify
web/src/features/payables/api.ts:154-175(useOpenInvoices) - Modify
web/src/features/payables/__tests__/PaymentsPage.test.tsx: handlers at lines 71, 194, 230, 300, 469; add one test after the I3 test (ends line 222) - Uses
web/src/test/msw/forKind.ts(created in WEB-4)
Interfaces: useOpenInvoices(f: { kind: InvoiceKind; supplier_id?: string; currency?: string }) unchanged signature.
When f.kind === 'purchase_invoice' it now pages through purchase_invoice then supplier_debit_note (open: true) and
concatenates; any other kind is asked as given. Callers: PaymentDialog.tsx:59 and InvoiceDrawer.tsx:55 (credit-note
offset targets) both pass 'purchase_invoice'. The payment batch dialog does not list invoices (its lines come from
payments/claims, batches/BatchDialog.tsx:89-91), so it needs no change.
-
Step 1: failing test + fixture updates. In
PaymentsPage.test.tsxaddimport { forKind } from '@/test/msw/forKind';. Existing handlers answer every kind with the same page, which would now list each invoice twice, so they must become kind-aware first:- lines 71-72, replace
http.get('*/v1/payables/invoices', () =>+HttpResponse.json({(the one whose next line isinvoices: [then{ id: 'i1', reference: 'PI-2026-0001', supplier_invoice_no: 'INV-1') withhttp.get('*/v1/payables/invoices', ({ request }) =>+forKind(request, 'purchase_invoice', 'invoices', {(the closing}),is textually the same). - lines 469-470, same replacement for the handler whose list is
invoices: ['i1', 'i2'].map((id, n) => ({. - lines 230-232 and 300-302 (identical text, use replace-all):
old
http.get('*/v1/payables/invoices', () =>\n HttpResponse.json({ invoices: [openInvoice('i1', 'PI-2026-0001')], next_cursor: null }),\n ),newhttp.get('*/v1/payables/invoices', ({ request }) =>\n forKind(request, 'purchase_invoice', 'invoices', {\n invoices: [openInvoice('i1', 'PI-2026-0001')],\n next_cursor: null,\n }),\n ), - line 194-203 (I3 test): after
queries.push(q);insertif (q.get('kind') === 'supplier_debit_note') return HttpResponse.json({ invoices: [], next_cursor: null });(the purchase_invoice pages stayqueries[0], asserted at lines 217-220).
New test, inside the same
describe(needsopenInvoiceandbankAccounts, in scope there), after the I3 test: - lines 71-72, replace
it('offers open supplier debit notes beside invoices, asking for each kind', async () => { const asked: string[] = []; server.use( http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [sup] })), http.get('*/v1/payables/payments', () => HttpResponse.json({ payments: [], next_cursor: null }), ), noWht(), bankAccounts(), http.get('*/v1/payables/invoices', ({ request }) => { const kind = new URL(request.url).searchParams.get('kind') ?? ''; asked.push(kind); return HttpResponse.json({ invoices: kind === 'supplier_debit_note' ? [{ ...openInvoice('d1', 'SDN-2026-0001'), kind: 'supplier_debit_note' }] : [openInvoice('i1', 'PI-2026-0001')], next_cursor: null, }); }), ); const { user } = await renderWithApp({ path: '/payables/payments/new', permissions: ['payables.payment.read', 'payables.payment.create', 'accounting.ledger.read'], }); const dialog = await screen.findByRole('dialog', { name: 'New payment' }); await user.click(within(dialog).getByRole('combobox', { name: 'Supplier' })); await user.click(await screen.findByRole('option', { name: /BEKAL/ })); expect(await within(dialog).findByLabelText('Pay PI-2026-0001')).toBeInTheDocument(); expect(await within(dialog).findByLabelText('Pay SDN-2026-0001')).toBeInTheDocument(); expect(asked).toEqual(['purchase_invoice', 'supplier_debit_note']); });-
Step 2: run, see red.
npx vitest run src/features/payables/__tests__/PaymentsPage.test.tsxExpected: only the new test fails (Unable to find a label with the text of: Pay SDN-2026-0001;askedis['purchase_invoice']); all edited existing tests still pass. -
Step 3: implement. Replace
useOpenInvoicesinpayables/api.ts(comment at 154-156 plus the function) with:
// useOpenInvoices is every document still owed (posted, not voided, something outstanding),// every page of it: a payment or offset picker that showed only the first page would hide// invoices that can be paid. Asking for purchase invoices also asks for supplier debit notes:// a debit note raises what we owe, so it is as payable as the invoice it adds to. The endpoint// takes one kind per call.export function useOpenInvoices(f: { kind: InvoiceKind; supplier_id?: string; currency?: string }) { return useQuery({ queryKey: payablesKeys.invoices({ ...f, open: true }), enabled: !!f.supplier_id, queryFn: async ({ signal }) => { const kinds: InvoiceKind[] = f.kind === 'purchase_invoice' ? ['purchase_invoice', 'supplier_debit_note'] : [f.kind]; const out: PurchaseInvoice[] = []; for (const kind of kinds) { let cursor: string | undefined; do { const page = unwrap( await api.GET('/payables/invoices', { params: { query: { ...f, kind, open: true, limit: 200, cursor } }, signal, }), ); out.push(...page.invoices); cursor = page.next_cursor ?? undefined; } while (cursor); } return out; }, });}-
Step 4: run, see green.
npx vitest run src/features/payablesExpected: all payables tests pass. -
Step 5: commit.
git add web/src/features/payables/api.ts web/src/features/payables/__tests__/PaymentsPage.test.tsxgit commit -m "Web: open supplier debit notes are payable targets in the payment and offset pickers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 36: WEB-8 — trade chain and imports archive labels
Section titled “Task 36: WEB-8 — trade chain and imports archive labels”Files:
- Modify
web/src/lib/i18n/locales/en/trade.json:140-145,ms/trade.json:144-149,zh/trade.json:131-136(invoiceKinds; used byweb/src/features/trade/ChainView.tsx:36) - Modify
web/src/lib/i18n/locales/en/imports.json:142-149,ms/imports.json:~142-149,zh/imports.json:142-149(archive.kinds; used byweb/src/features/imports/ArchiveList.tsx:48,140) - Modify
web/src/lib/i18n/debitNoteKeys.test.ts(extendPATHS)
Interfaces: TradeChainInvoice.kind and ArchivedDocument.kind (openapi enums extended per spec) are rendered by
dynamic key lookup, so a missing key shows the raw key. Spec default: archive yes, trade link no, but the chain shape
allows the kind, so the label is cheap insurance.
- Step 1: failing test. Extend
PATHSindebitNoteKeys.test.ts:
['trade', ['invoiceKinds', 'customer_credit_note']], // control ['trade', ['invoiceKinds', 'customer_debit_note']], ['trade', ['invoiceKinds', 'supplier_debit_note']], ['imports', ['archive', 'kinds', 'customer_credit_note']], // control ['imports', ['archive', 'kinds', 'customer_debit_note']], ['imports', ['archive', 'kinds', 'supplier_debit_note']],and, so the controls are really asserted, add to the it.each body before the missing computation:
expect(at(locale, 'trade', ['invoiceKinds', 'customer_credit_note'])).toBeTruthy(); expect(at(locale, 'imports', ['archive', 'kinds', 'customer_credit_note'])).toBeTruthy();(If archive is not the top-level key holding kinds in imports.json, adjust the path to the object that contains
"sectionTitle"/"kinds" at lines 141-142, verified with grep -n '"archive"' web/src/lib/i18n/locales/en/imports.json.)
-
Step 2: run, see red.
npx vitest run src/lib/i18n/debitNoteKeys.test.tsExpected: each locale fails withtrade:invoiceKinds.customer_debit_note, trade:invoiceKinds.supplier_debit_note, imports:archive.kinds.customer_debit_note, imports:archive.kinds.supplier_debit_notemissing. -
Step 3: implement. Add (commas as needed) to each
invoiceKindsandarchive.kinds:- en trade:
"customer_debit_note": "Customer debit note","supplier_debit_note": "Supplier debit note"; en imports: both"Debit note". - ms trade:
"Nota debit pelanggan","Nota debit pembekal"; ms imports: both"Nota debit". - zh trade:
"客户借项通知","供应商借项通知"; zh imports: both"借项通知单".
- en trade:
-
Step 4: run, see green.
npx vitest run src/lib/i18n src/drift/i18nparity.test.ts -
Step 5: commit.
git add web/src/lib/i18n/locales/en/trade.json web/src/lib/i18n/locales/ms/trade.json web/src/lib/i18n/locales/zh/trade.json web/src/lib/i18n/locales/en/imports.json web/src/lib/i18n/locales/ms/imports.json web/src/lib/i18n/locales/zh/imports.json web/src/lib/i18n/debitNoteKeys.test.tsgit commit -m "Web: debit note labels for the trade chain and the imports archive
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 37: WEB-9 — e2e spec, debit note issued, receipt, aging zero
Section titled “Task 37: WEB-9 — e2e spec, debit note issued, receipt, aging zero”Files:
- Create
web/e2e/debit-notes.spec.ts(a new file:web/e2e/receivables.spec.tsis not edited; the user’s modifiedseed.ts,README.md,sample/are untouched) - Reads:
web/e2e/support.ts(as,ensureFinanceAR,runTag, lines 24-45, 155-176),web/e2e/receivables.spec.ts(pattern)
Interfaces: Uses the same screen labels as receivables.spec.ts plus the new ones from WEB-1/WEB-2: tab “Debit notes”,
button “New debit note”, “Issue invoice”. Needs the full stack with the debit-note backend (API plan) running, started per
web/e2e/README.md; per the agent-stack-isolation note use a unique ERP_STACK/ERP_PG_PORT. Reference prefix is the
numbering plan’s DN: the assertion uses /^DN-/ so a different year/sequence shape does not break it; if the numbering
plan picks another prefix, change that one regexp (two places).
- Step 1: write the spec (this is the failing test; it cannot pass until WEB-1/2/4 and the Go side are in).
import { expect, test, type Page } from '@playwright/test';import { as, ensureFinanceAR, runTag } from './support';
const chip = (page: Page) => page .getByRole('dialog') .getByRole('heading', { level: 3 }) .first() .locator('xpath=following-sibling::*[1]');
test.beforeAll(async () => { await ensureFinanceAR();});
test('debit note issued → aging → receipt applied to it → aging zero', async ({ browser }) => { const tag = runTag(); const code = `E2E${tag.replace(/\D/g, '').slice(-6)}`; const name = `${tag} Kedai Tambahan Sdn Bhd`;
// 1. Finance creates the customer. const fin = await as(browser, 'finance'); await fin.goto('/receivables/customers'); await fin.getByRole('button', { name: 'New customer' }).first().click(); const cd = fin.getByRole('dialog'); await cd.getByLabel('Code', { exact: true }).fill(code); await cd.getByLabel('Registered name').fill(name); await cd.getByRole('button', { name: 'Save' }).click(); await expect(fin).toHaveURL(/\/receivables\/customers\/[0-9a-f-]{36}$/);
// 2. Finance issues a debit note with no original invoice (the original is optional). await fin.goto('/receivables/invoices?kind=customer_debit_note'); await fin.getByRole('button', { name: 'New debit note' }).first().click(); const dn = fin.getByRole('dialog'); await dn.getByRole('combobox', { name: 'Customer' }).click(); await fin.getByRole('option', { name: new RegExp(code) }).click(); await dn.getByLabel('Reason for the debit').fill('Freight undercharged'); await dn.getByLabel('Description').fill('Freight'); await dn.getByRole('combobox', { name: 'Income account' }).click(); await fin.getByRole('option', { name: /4100/ }).click(); await dn.getByRole('combobox', { name: 'Tax code' }).click(); await fin.getByRole('option', { name: /SVT8/ }).click(); await dn.getByLabel('Unit price').fill('100.00'); await expect(dn.getByText('108.00')).toBeVisible(); // 100 + SST 8 await dn.getByRole('button', { name: 'Issue invoice' }).click(); await expect(chip(fin)).toHaveText('Issued'); await expect(fin.getByRole('dialog').getByText(/^DN-/)).toBeVisible();
// PRESENCE: issued and unpaid, the customer owes it, so they are in AR aging. await fin.goto('/reports?report=ar_aging'); await expect(fin.getByText(name)).toBeVisible();
// 3. Finance records a receipt against the debit note. await fin.goto('/receivables/receipts'); await fin.getByRole('button', { name: 'New receipt' }).first().click(); const rd = fin.getByRole('dialog'); await rd.getByRole('combobox', { name: 'Customer' }).click(); await fin.getByRole('option', { name: new RegExp(code) }).click(); await rd.getByRole('combobox', { name: 'Received into' }).click(); await fin.getByRole('option', { name: /1520/ }).click(); await rd.getByLabel('Amount received').fill('108.00'); await rd.getByLabel(/^Apply DN-/).fill('108.00'); await rd.getByRole('button', { name: 'Record receipt' }).click(); await expect(chip(fin)).toHaveText('Received');
// 4. ABSENCE: paid in full, the customer is gone from AR aging. await fin.goto('/reports?report=ar_aging'); await expect(fin.getByText(name)).toHaveCount(0);});-
Step 2: typecheck and confirm it is red against the pre-implementation stack.
cd /Users/limziyang/Documents/repos/mcomtask/web && npx tsc --noEmit -p tsconfig.json(spec compiles), then with the stack up pere2e/README.md:npx playwright test e2e/debit-notes.spec.ts. Expected before WEB-1/2/4: fails atgetByRole('button', { name: 'New debit note' })(or earlier: tab absent); after all web tasks and the backend plan: passes. Watch it fail once at the aging step by running it against a backend whoseaging.gostill treats non-sales_invoiceas a credit (spec: “a debit note ages positive; fails today”): the presence assertiongetByText(name)is then absent from aging. -
Step 3: no implementation edits (UI done in WEB-1/2/4).
-
Step 4: run green once the stack includes everything.
npx playwright test e2e/debit-notes.spec.ts -
Step 5: commit.
git add web/e2e/debit-notes.spec.tsgit commit -m "Web e2e: debit note issued, receipt applied, aging zero
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 38: WEB-10 — native-review sheet rows and final web verification
Section titled “Task 38: WEB-10 — native-review sheet rows and final web verification”Files:
- Modify
docs/review/ms-review.csv,docs/review/zh-review.csv(append rows; convention indocs/review/README.md: columnsfile,key,english,current,notes_blank;fileis the locale-relative path such asms/receivables.json;notes_blankleft empty)
Interfaces: none. The README says nothing is signed off until a named native reviewer marks it; these rows go onto the sheets, and no string is called reviewed.
-
Step 1: check the files end with a newline.
tail -c1 docs/review/ms-review.csv | xxdand the same for zh (expect0a; if not,printf '\n' >> filefirst). -
Step 2: append the Malay rows.
cat >> docs/review/ms-review.csv <<'EOF'ms/receivables.json,invoices.tabs.customer_debit_note,Debit notes,Nota debit,ms/receivables.json,invoices.new.customer_debit_note,New debit note,Nota debit baharu,ms/receivables.json,invoices.detailTitle.customer_debit_note,Debit note,Nota debit,ms/receivables.json,invoices.fields.originalOptional,Original invoice (optional),Invois asal (pilihan),ms/receivables.json,invoices.fields.noOriginal,No original invoice,Tiada invois asal,ms/receivables.json,invoices.fields.reasonDebit,Reason for the debit,Sebab debit,ms/payables.json,invoices.tabs.supplier_debit_note,Debit notes,Nota debit,ms/payables.json,invoices.new.supplier_debit_note,New debit note,Nota debit baharu,ms/payables.json,invoices.detailTitle.supplier_debit_note,Supplier debit note,Nota debit pembekal,ms/payables.json,invoices.fields.originalDebit,Original invoice (optional),Invois asal (pilihan),ms/payables.json,invoices.fields.noOriginal,No original invoice,Tiada invois asal,ms/trade.json,invoiceKinds.customer_debit_note,Customer debit note,Nota debit pelanggan,ms/trade.json,invoiceKinds.supplier_debit_note,Supplier debit note,Nota debit pembekal,ms/imports.json,archive.kinds.customer_debit_note,Debit note,Nota debit,ms/imports.json,archive.kinds.supplier_debit_note,Debit note,Nota debit,EOF- Step 3: append the Chinese rows.
cat >> docs/review/zh-review.csv <<'EOF'zh/receivables.json,invoices.tabs.customer_debit_note,Debit notes,借项通知单,zh/receivables.json,invoices.new.customer_debit_note,New debit note,新建借项通知单,zh/receivables.json,invoices.detailTitle.customer_debit_note,Debit note,借项通知单,zh/receivables.json,invoices.fields.originalOptional,Original invoice (optional),原发票(可选),zh/receivables.json,invoices.fields.noOriginal,No original invoice,无原发票,zh/receivables.json,invoices.fields.reasonDebit,Reason for the debit,借记原因,zh/payables.json,invoices.tabs.supplier_debit_note,Debit notes,借项通知单,zh/payables.json,invoices.new.supplier_debit_note,New debit note,新建借项通知单,zh/payables.json,invoices.detailTitle.supplier_debit_note,Supplier debit note,供应商借项通知单,zh/payables.json,invoices.fields.originalDebit,Original invoice (optional),原发票(可选),zh/payables.json,invoices.fields.noOriginal,No original invoice,无原发票,zh/trade.json,invoiceKinds.customer_debit_note,Customer debit note,客户借项通知,zh/trade.json,invoiceKinds.supplier_debit_note,Supplier debit note,供应商借项通知,zh/imports.json,archive.kinds.customer_debit_note,Debit note,借项通知单,zh/imports.json,archive.kinds.supplier_debit_note,Debit note,借项通知单,EOF- Step 4: final verification of the web part (not the full Go suite):
cd /Users/limziyang/Documents/repos/mcomtask/webnpx tsc --noEmit -p tsconfig.jsonnpx vitest run src/features/receivables src/features/payables src/features/trade src/features/imports src/lib/i18n src/driftnpx eslint src/features/receivables src/features/payables src/test/msw/forKind.ts src/lib/i18n/debitNoteKeys.test.ts e2e/debit-notes.spec.tsnpx prettier --check src e2e/debit-notes.spec.tsExpected: typecheck clean, tests green, lint clean. Then once, before the final commit: npx vitest run (full web suite).
Watch-it-fail check for the new guard: temporarily rename "customer_debit_note" in zh/receivables.json tabs, run
npx vitest run src/lib/i18n/debitNoteKeys.test.ts src/drift/i18nparity.test.ts, read both red messages, restore.
- Step 5: commit.
git add docs/review/ms-review.csv docs/review/zh-review.csvgit commit -m "Review sheets: ms and zh debit note strings awaiting native review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Strings awaiting native review (best-effort, not signed off)
Section titled “Strings awaiting native review (best-effort, not signed off)”All listed in WEB-10’s rows. Malay: Nota debit (debit note, standard Malaysian accounting term), Nota debit baharu,
Nota debit pembekal / pelanggan, Invois asal (pilihan), Tiada invois asal, Sebab debit. Chinese (Simplified):
借项通知单 (debit note; mirrors the repo’s 贷项通知单 for credit note, although zh/einvoice.json uses 贷记单 for
credit note, so a reviewer should pick one family), 新建借项通知单, 供应商借项通知单, 客户借项通知, 原发票(可选),
无原发票, 借记原因 (mirrors 贷记原因).
Task 0: section break (not a task)
Section titled “Task 0: section break (not a task)”Open points for the caller
Section titled “Open points for the caller”- WEB-4/WEB-7 depend on assumption 2 (single
kindper list call); if the API plan allows several, the helper and the loop collapse to one call and theforKindfixture edits are unnecessary. invoices.subtitlecopy (“… and credit notes …”) and the Go-side printing/numbering prefixes are out of this plan.
Task 39: DOC-1 — Roadmap, spec cross-references, deploy note
Section titled “Task 39: DOC-1 — Roadmap, spec cross-references, deploy note”Files:
- Modify:
docs/ROADMAP.md(add a checklist item under Phase 2 AR/AP’s neighbourhood, e.g. after the AR block near line ~367; read the file’s current structure first; it has uncommitted user edits, so useEditon the exact lines, never rewrite the file) - Modify:
docs/superpowers/specs/2026-09-27-accounts-receivable-design.md(the “No debit notes in v1” lines:27,:154: append “Superseded by 2026-10-05-debit-notes-design.md”) - Modify:
docs/superpowers/specs/2026-09-27-myinvois-design.md(:57same pointer) - Modify:
docs/DEPLOY_NOTES.md(append a section)
Interfaces: none.
- Step 1: Roadmap. Add (use the file’s own checkbox/format; read the neighbouring AR item first):
- [ ] Debit notes — AR `customer_debit_note` and AP `supplier_debit_note` Design: [`2026-10-05-debit-notes-design.md`](superpowers/specs/2026-10-05-debit-notes-design.md), migration `01070` Plan: [`2026-10-05-debit-notes.md`](superpowers/plans/2026-10-05-debit-notes.md) - [ ] Open decision (adviser): validate MyInvois types `03` and `13` in the LHDN sandboxTick the boxes as the tasks land (AGENTS.md: “tick items as they land”).
-
Step 2: Spec pointers. Append to each superseded sentence:
— superseded: see [debit notes](2026-10-05-debit-notes-design.md). -
Step 3: Deploy note. Append to
docs/DEPLOY_NOTES.md:
## Debit notes (migration 01070)
After migrating, **re-provision every organization** (`POST /v1/org/provision`) so it gainsthe `customer_debit_note` and `supplier_debit_note` workflow kinds (a new seeded kind reaches anexisting organization only that way; see "Wave 3 base"). Until then an organization cannot createa debit note. MyInvois types `03`/`13` are not sandbox-validated: leave debit-note e-invoicingdisabled for an organization until the adviser has validated them.(If the EX tasks implement a per-organization e-invoice switch for the new kinds, reword the last sentence to name it.)
-
Step 4: Final verification (once, before the merge). Run
go build ./... && go test -p 1 ./... && pnpm -C web run lint && pnpm -C web exec vitest run. Report exactly what passed and failed. -
Step 5: Commit
git add docs/superpowers/specs/2026-09-27-accounts-receivable-design.md docs/superpowers/specs/2026-09-27-myinvois-design.md docs/DEPLOY_NOTES.md docs/ROADMAP.mdgit commit -m "Docs: debit notes on the roadmap; supersede the no-debit-notes decisions" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"(docs/ROADMAP.md already carries the user’s uncommitted edits: before this commit run git diff docs/ROADMAP.md, and if it contains hunks that are not yours, tell the user and git add -p only your hunk.)