Skip to content

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

  • Dependencies point downward only: modules/ → platform/ → kernel/. Modules never write journal lines; the debit note posts through accounting/ 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, policy org_isolation. No new table is added by this plan.
  • Vocabulary values are strings: kinds are customer_debit_note and supplier_debit_note verbatim.
  • 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, then make 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 use testdb.Start.
  • Use a unique ERP_STACK / ERP_PG_PORT per 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/): never git 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, AP SDN. 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) and 13 (self-billed debit note) are not sandbox-validated; the roadmap carries that as an open item for the adviser.

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.kind accepts 'customer_debit_note'; purchase_invoice.kind accepts 'supplier_debit_note'; both allocation guards accept a debit note as the invoice_id target and still refuse it as credit_note_id source; tax_return_line.source_kind, einvoice_submission.document_type (03,13) and archived_document.kind accept 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 StatementBegin
CREATE 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 StatementBegin
CREATE 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 StatementBegin
DO $$ 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 StatementEnd
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')));
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

Terminal window
git add db/migrations/01070_debit_notes.sql internal/kernel/db/migrationranges_test.go internal/platform/receivables/migration01070_test.go
git 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, new settlable)
  • Modify: internal/platform/receivables/graphs.go:10-17 (register), append DebitNoteGraph
  • 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 (add DebitNote)
  • 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} from Rules(), re-run TestADebitNotePostsLikeAnInvoice, read the “no posting rule” failure, restore.

  • Step 6: Commit

Terminal window
git add internal/platform/receivables internal/platform/workflow/model.go cmd/worker/wiring_test.go internal/app
git 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: Allocate and checkReceipt accept 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 settlable use in allocation.go, run TestACreditNoteOffsetsADebitNoteButNotTheOtherWayRound, read ErrNotAllocatable, restore.

  • Step 6: Commit

Terminal window
git add internal/platform/receivables/allocation.go internal/platform/receivables/receipt.go internal/platform/receivables/debitnote_allocation_test.go
git 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 (applied join) and :93-94 (sign CASE)
  • 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 CASE lines, re-run, read the negative amount, restore.

  • Step 6: Commit

Terminal window
git add internal/platform/receivables/aging.go internal/platform/receivables/aging_test.go
git 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>"

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=1 Expected: PASS (these pin behaviour that already holds, so they cannot fail on their own).

  • Step 3: Watch each fail once — (a) temporarily add KindDebitNote to the kind test at opening.go:63 and re-run TestAnOpeningDebitNote…: it fails; revert with git checkout internal/platform/receivables/opening.go. (b) temporarily add KindDebitNote: "delivery_order" to deliveryKind: 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". Run go test ./internal/platform/receivables/... -count=1 -p 1 → PASS.

  • Step 5: Commit

Terminal window
git add internal/platform/receivables/deliverymatch.go internal/platform/receivables/debitnote_edges_test.go
git 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.yaml at 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 SalesDocumentKind contains customer_debit_note, PurchaseInvoice.kind/PurchaseInvoiceInput.kind contain supplier_debit_note, EInvoiceSubmission.document_type contains 03 and 13. 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) and ApAgingRow.kind (~:20290); PurchaseInvoiceInput.kind (~:20435) and PurchaseInvoice.kind (~:20467); EInvoiceSubmission.source_kind (~:21259) and .document_type (~:21261, add "03", "13"); TaxReturnLine.source_kind (~:21720); TradeChainInvoice.kind (~:21959); ArchivedDocument.kind (~:15831); the inline kind query parameters of GET /payables/invoices (~:7358), GET /payables/matchable-lines (~:7510, do not add — debit notes are unmatched in v1; leave) and GET /receivables/matchable-lines (~:9529, leave). Line numbers drift: locate each by grep -n "customer_credit_note\|supplier_credit_note" api/openapi.yaml. Also update the imports description (~:15738-15779) to say opening documents may be debit_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 checkout that generated file, re-run make generate.

  • Step 6: Commit

Terminal window
git add api/openapi.yaml api/public-openapi.yaml internal/gen web/src/lib/api/schema.d.ts
git commit -m "API: debit-note kinds in the OpenAPI contract; regenerate" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

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).

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)”
  1. Migration (one, next free number) has already run in the test database, with these AP effects: purchase_invoice_kind_known accepts supplier_debit_note; the original-link CHECK (purchase_invoice_original_only_credit_note, name kept) allows an optional original_invoice_id on supplier_debit_note; the ap_allocation target trigger accepts a supplier_debit_note as invoice_id target; archived_document_kind_known accepts supplier_debit_note. Tasks AP-4, AP-5 and AP-6 fail on a DB without it.
  2. Workflow: workflow.AccrualOnlyKinds (internal/platform/workflow/model.go:58) gains supplier_debit_note. Graph validity (workflowtest.RequireSeededGraphValid) does not depend on it (validate.go:474 only adds a check), so AP-1 can land first.
  3. internal/modules/payables/errors.go:130 keeps mapping the constraint name purchase_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 behind checkInput (AP-4). Cosmetic, left to the API/web section.
  4. The e-invoice, tax return, openapi, web, AR, printing and webhooks sections own everything else. Notably the self-billed payment gate selfBilledPayable (transition.go, reads einvoice_submission.purchase_document_id) already works for a debit note once the e-invoice section queues type 13 submissions.

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) calls invoiceShares/workflow.SetShares only for KindInvoice; credit notes already skip it. Shares exist solely to drive the account_approver grant, which the validator permits only for workflow.SignoffKinds (validate.go:221, model.go:53), and documentGraph grants only the OWNER seat. Adding the kind to SignoffKinds would be a workflow-engine change beyond the spec. Revisit when a customer asks for per-account sign-off on supplier extra charges: change inv.Kind == KindInvoice to inv.Kind != KindCreditNote at invoice.go:664 and append the kind to SignoffKinds together. 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. No OB-SDN prefix, no debit_note type in imports/documents.go. CreateOpeningInvoice (opening.go:56-60) already refuses an unknown kind with kind_known; AP-9 pins that refusal.
  • Assets register: exclude. internal/platform/assets/asset.go:228 and :406 filter i.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 KindInvoice of the same supplier whose workflow state has semantic accrual (issued, not voided) and cancelled_at IS NULL. A self-billed debit note’s original, when present, must additionally be a self-billed invoice (mirrors D13 checkSelfBilledOriginal); 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 are SDN-2026-0001); numbering.Allocate scopes by kind string, so there is no collision with PI/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 helper PostedDebitNote, used by AP-3, AP-5, AP-6, AP-7, AP-11.

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 after KindCreditNote at L14; SourceKinds at L46)
  • Modify: internal/platform/payables/graphs.go (init L11-21; graph comment L47-52; new func after CreditNoteGraph L77-79)
  • Modify: internal/platform/payables/graphs_test.go (TestTheSeededGraphsAreValid L14-18; append a test)

Interfaces:

  • Produces: payables.KindDebitNote = "supplier_debit_note", payables.DebitNoteGraph(ownerSeat uuid.UUID) workflow.Graph, payables.SourceKinds containing KindDebitNote.

  • Consumes: documentGraph(kind, ownerSeat, posted) (graphs.go:46), workflow.RegisterSeededGraph (workflow/seed.go:135).

  • Step 1: Write the failing tests. In graphs_test.go change 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=1 Expected: build failure undefined: payables.KindDebitNote / undefined: payables.DebitNoteGraph.

  • Step 3: Implement. doc.go: package comment L2-3 purchase 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.go git 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 (DueOn comment L139; prefixes L196; guard message L572; create due logic L586-589; update due logic L753-756)
  • Modify: internal/platform/payables/payablestest/env.go (add helper after Void, 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=1 Expected: FAIL at e.Invoice for the debit note with field=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
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. Also go test ./internal/platform/payables/ -run 'TestCreateInvoiceTotalsFreezesBaseAndDefaultsTheDueDate|TestUpdateInvoiceReplaces' -count=1 still ok.
  • Step 5: Commit. git add internal/platform/payables/invoice.go internal/platform/payables/payablestest/env.go internal/platform/payables/debit_note_test.go git 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; label map L67)
  • Modify: cmd/worker/wiring_test.go (TestWorkerLinksThePayablesModule table L160-161)
  • Modify: internal/platform/payables/debit_note_test.go (append test; add import ledger)

Interfaces:

  • Consumes: AccrualRule{KindName} (posting.go:36-44), mirror := inv.Kind == KindCreditNote (posting.go:56, unchanged, which is what keeps a debit note unmirrored), sumSide and requireBalancedWithPartyOnControl (posting_test.go:20,28), payablestest.PostedDebitNote.

  • Produces: payables.Rules() containing AccrualRule{KindName: KindDebitNote}; journal source_kind = supplier_debit_note, description prefix Supplier debit note .

  • Step 1: Write the failing tests. Add "github.com/mcom/erp/internal/platform/accounting/ledger" to the import block of debit_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 in e.Post (approve transition): “this document cannot be posted” / no posting rule for supplier_debit_note/accrual. go test ./cmd/worker/ -run TestWorkerLinksThePayablesModule -count=1 -> FAIL no 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. Also go 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.go git 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.OriginalInvoiceID comment L90-93; InvoiceInput comment L148; errOriginalOnlyCreditNote L337-339; new func after checkSelfBilledOriginal L343-360; checkInput self-billed branch L419-425 and non-self-billed branch L451)
  • Modify: internal/platform/payables/debit_note_test.go (append tests; add imports uuid, money)

Interfaces:

  • Consumes: GetInvoice, Invoice.Semantic == posting.Accrual, Invoice.CancelledAt, BasisSelfBilled; test helpers e.EInvoiceInScope, e.Doc, e.Post, e.Void.

  • Produces: checkDebitNoteOriginal(ctx, tx, supplier uuid.UUID, originalID *uuid.UUID, selfBilled bool) error; refusal field=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 of debit_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=1 Expected: first test FAILs at require.Equal(t, inv.ID, *dn.OriginalInvoiceID) (nil pointer: the non-self-billed branch at invoice.go:451 discards the original) ; second FAILs at “an approved self-billed original saves” with rule=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:
// 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 = nil with:
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. Expected ok.
  • Step 5: Commit. git add internal/platform/payables/invoice.go internal/platform/payables/debit_note_test.go git 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 (checkPayment line check L251-252)
  • Modify: internal/platform/payables/debit_note_test.go (append test)

Interfaces:

  • Consumes: outstanding helper (allocation_test.go:15), e.Pay, e.Payment, e.VoidPayment, payables.ErrHasAllocations (invoice.go:34), payables.TransitionInvoice; InvoiceOutstanding already reads invoice_id for any non-credit-note (allocation.go:83-86), so the debit note needs no change there. Needs assumption 1 (allocation trigger).

  • Produces: checkPayment accepting KindDebitNote lines.

  • 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=1 Expected: FAIL in e.Payment (payablestest.Env.PaymentWithWHT requires no error): ErrInvoiceNotPayable “only this supplier’s posted invoices in the payment’s currency can be paid”.

  • Step 3: Implement. payment.go L251-252 change inv.Kind != KindInvoice to inv.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). Expected ok.
  • Step 5: Commit. git add internal/platform/payables/payment.go internal/platform/payables/debit_note_test.go git 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 (ErrNotAllocatable L193; AllocateCreditNote target check L224-226)
  • Modify: internal/platform/payables/debit_note_test.go (append test)

Interfaces:

  • Consumes: allocate, reverse, outstanding helpers (allocation_test.go:15,28,37 region), payables.ErrOverAllocation, payables.ErrNotAllocatable; the source check cn.Kind != KindCreditNote (L217) already refuses a debit note as source, so only the target check changes.

  • Produces: AllocateCreditNote accepting a KindDebitNote target.

  • 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=1 Expected: 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.go L225:

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. Expected ok.
  • Step 5: Commit. git add internal/platform/payables/allocation.go internal/platform/payables/debit_note_test.go git 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 (agingSQL applied join L121; doc comment L106-108)
  • Modify: internal/platform/payables/debit_note_test.go (append test; uses the aging helper in aging_test.go:28)

Interfaces:

  • Consumes: aging(t, e, asOf) (aging_test.go:28), AgingRow.Outstanding/Kind, expensestest.Today. The sign CASE (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: applied CTE matching a.invoice_id = p.id for supplier_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=1 Expected: the first block passes (positive, 100.0000: it is the “watch it age positive” presence) and the test FAILs at "reduced by the payment": got 100.0000, want 60.0000.

  • Step 3: Implement. aging.go L121:

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. Expected ok.
  • Step 5: Commit. git add internal/platform/payables/aging.go internal/platform/payables/debit_note_test.go git 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 (matchedLine opening switch L117-122; leave matchedKind L98 and MatchableLines L373 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 with field=kind rule=kind_known).

  • Produces: refusal lines[0].trade_line_id rule=trade_line_kind with a detail naming debit notes. Today the refusal happens only because matchedKind[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=1 Expected: FAIL at the debit note’s require.Equal(t, "trade_line_kind", fe.Rule) with "trade_line" (the unknown-line lookup answers first) .

  • Step 3: Implement. In matchedLine, make the first switch (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. Expected ok.
  • Step 5: Commit. git add internal/platform/payables/match.go internal/platform/payables/debit_note_test.go git 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:32 openingPrefixes and :56-60 already 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 -> expected ok (no code change). To prove the guard bites, temporarily add KindDebitNote: "OB-SDN" to openingPrefixes (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). If CreateOpeningInvoice demands migration fixtures the presence call lacks, copy them from goLiveWorld (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.go git 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 import time)

Interfaces:

  • Consumes: invoiceAt-style helpers in register_test.go (withholdW109B L33, paymentAt L40, register L69, rowFor L79), 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.refs already names any allocated document because it joins purchase_invoice pi2 by a.invoice_id.

  • Step 1: Write the failing test (mirrors TestWithholdingRegisterCreditedEarlier, register_test.go:168). Add "time" to the imports of debit_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=1 Expected: FAIL at require.NotNil(t, r.CreditedOn) (the references line passes, which is the presence).

  • Step 3: Implement. register.go L62:

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. Expected ok.
  • Step 5: Commit. git add internal/platform/payables/register.go internal/platform/payables/debit_note_test.go git 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 after TestAPostedPurchaseLineRegistersOnceAndPostsNothing, L16-39)
  • No production change (decision above): internal/platform/assets/asset.go:228 (readSource) and :406 (Candidates) filter i.kind = 'purchase_invoice'.

Interfaces:

  • Consumes: assets test world (w.PPE, w.Class, w.register, jan15), payablestest.Env.InvoiceOn/Post/PostedDebitNote… note PostedDebitNote posts on 6140; this test needs w.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 -> expected ok. To prove the guard bites: temporarily change asset.go L228 and L406 i.kind = 'purchase_invoice' to i.kind IN ('purchase_invoice', 'supplier_debit_note'), re-run, expect FAIL (require.False(offered[dn...])), then revert with git 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.go git commit -m "assets: guard that supplier debit note lines are not asset candidates" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

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.


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 03 and 13 are NOT validated against LHDN’s sandbox. The golden files this plan creates are the encoder’s own output (as testdata/README.md already says for 01/02/11), and BillingReference on 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).

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 -> issued by the preparer; AP draft -> pending_review -> posted), Rules() entries, workflow.AccrualOnlyKinds, allocation/receipt targets, receivables.CreateDocument accepts Kind: KindDebitNote with optional OriginalInvoiceID and Reason, payables.CreateInvoice accepts Kind: KindDebitNote with optional OriginalInvoiceID.
  • No helper is assumed in receivablestest/payablestest: every test below builds its debit notes with a local helper (einvoicetest.DebitNote, einvoicetest.SelfBilledDebitNote, debitNote in taxreturn tests, debitNoteOf in webhooks tests).

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)”
  1. countrypack/my/myinvois/ubl.go needs no edit: InvoiceTypeCode is d.Type verbatim (L41) and BillingReference is written whenever d.Original != nil (L56-64). The check in check.go already requires an original only for 02/12 (L34-41), so 03/13 are optional-original by construction; EX-1 pins that with tests. What check.go does need: the self-billed test (L29) and selfBilledDefaults (L54) must include type 13, or a foreign supplier on a self-billed debit note gets no defaults.
  2. taxreturn/compute.go document basis (L124) negates only kind == sourceCreditNote, so a debit note is already positive there and its SourceKind is its own kind. The real defect is the payment-basis loader (loadPaymentFacts): query 1 (L258) takes only kind = 'sales_invoice' as an invoice; query 2 (L281) groups by coalesce(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. The SourceLine it produces keeps its own kind (L173 hard-codes sourceInvoice).
  3. 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 in BillingReference.
  4. webhooks/catalog.go is kind-agnostic (salesDocument(ctx, tx, id, kind) L154 takes the kind from the event): no code change, one test (EX-9).
  5. imports/attach.go L134 permission-by-kind loops ArchiveSalesKinds, 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.go L30-33 (type constants).
  • Modify internal/platform/countrypack/my/myinvois/check.go L29 (selfBilled), L54 (selfBilledDefaults).
  • Modify internal/platform/countrypack/my/myinvois/fixtures_test.go (after creditNote(), L51; after selfBilled(), L68).
  • Modify internal/platform/countrypack/my/myinvois/ubl_test.go L41-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 package myinvois_test.

  • Consumes einvoice.Document, einvoice.Reference, myinvois.Codec{}.Check/Encode.

  • Step 1: failing tests. Append to fixtures_test.go after creditNote():

// 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 error undefined: einvoice.TypeDebitNote / einvoice.TypeSelfBilledDebitNote.

  • Step 3: implement. doc.go, in the const block after TypeCreditNote / 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.TypeSelfBilledDebitNote

check.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 in check.go; TestASelfBilledDebitNoteGetsTheSelfBilledDefaults must go red (supplier.tin required); restore.

  • Step 5: generate the two goldens and read them. go test ./internal/platform/countrypack/my/myinvois/ -run TestTheEncoderMatchesTheGoldenFiles -update Then grep -o '"InvoiceTypeCode":\[{"_":"[0-9]*"' internal/platform/countrypack/my/myinvois/testdata/debit_note.json internal/platform/countrypack/my/myinvois/testdata/self_billed_debit_note.json must print 03 and 13, and grep -c BillingReference must be 1 for debit_note.json and 0 for self_billed_debit_note.json. git diff --stat must 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.json
git 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.go L26-35 (salesDocument).
  • Modify internal/platform/einvoice/guard.go L16 (registration loop), comment L21 and L80-83.
  • Modify internal/platform/einvoice/einvoicetest/env.go (add DebitNote; extend Encode, 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 package einvoice_test: markValid, billingReference, fields.

  • Produces (*einvoicetest.Env).DebitNote(t, c receivables.Customer, original *receivables.Document, reason string, lines ...receivables.LineInput) receivables.Document; Encode now handles a debit note (originalUUID variadic as for a credit note).

  • Step 1: failing tests. In einvoicetest/env.go add after Encode (needs no new imports; receivables, pgx, require are 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.Background
var _ pgx.Tx
var _ 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: TestADebitNoteWithoutAnOriginalIsQueuedWithNoBillingReference fails at SubmissionFor: einvoice: not found (no guard is registered for customer_debit_note, so nothing was queued); TestADebitNoteIsQueuedAsType03... fails at the first ErrorAs (issue succeeded, no refusal); ...IncompleteBuyer... fails ErrorAs (issued unchecked).

  • Step 3: implement. guard.go L16:

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.go
git 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.go L19 (loop), L24-29 comment, L106-117 (purchaseDocument).
  • Modify internal/platform/einvoice/einvoicetest/env.go (add SelfBilledDebitNote after SelfBilledCreditNote, 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 helpers e.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.go after SelfBilledCreditNote:

// 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: TestASelfBilledDebitNoteIsQueuedAsType13AtApproval fails at ErrorAs (no guard, approve succeeded or returned nil); ...NeedsScope fails ErrorAs (nil error); ...WithoutAnOriginal fails in PurchaseSubmission (not found).

  • Step 3: implement. guard_payables.go L19:

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.go
git 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.go L174 (salesVoidExits), L196 (RegisterPublishCheck loop), comment above L170 (“each sales graph”).
  • Modify internal/platform/einvoice/debitnote_test.go (append; this is where the context/pgx/workflow imports become used, so delete the three var _ = 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 at ErrorAs (TrySaveSetting returned nil: the loop never looks at the debit-note graph); second fails at the last ErrorAs (publish succeeded).

  • Step 3: implement. setting.go L174 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: use testdb.Start, per-package only).

  • Step 5: commit.

git add internal/platform/einvoice/setting.go internal/platform/einvoice/debitnote_test.go
git 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.go L18-22 (constants), L173 (payment-basis SourceLine), L255-271 (query 1 and its scan), L281-290 (query 2), L311 (branch), doc comment L242-249.
  • Modify internal/platform/taxreturn/attribute.go L30-38 (invoiceFacts: add Kind).
  • Modify internal/platform/taxreturn/compute_test.go (append tests and a local helper).

Interfaces:

  • Consumes receivables.KindDebitNote, receivables.CreateDocument/DocumentInput, existing test helpers world, line, sepOct, rows, linesOf, receipt (compute_test.go L100), e.Issue, e.Invoice, e.IssueCreditNote, e.CreditNote, constants r10, 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 via compute()’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: TestADebitNoteIsItsOwnPaymentBasisInvoice fails: the first require.Equal(... "12.0000") gets 8.0000 or 4.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 shows 50.0000 / 4.0000 (1). TestADebitNoteIsAPositiveLineEvenWithItsOriginal passes on first run (document basis already positive); it needs its own watch-it-fail, Step 5.

  • Step 3: implement. compute.go constants (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 lines

attribute.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) to if kind != sourceInvoice {: TestADebitNoteIsAPositiveLineEvenWithItsOriginal must go red (1000.0000 / 100.0000 plus -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.go
git 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 after TestFinaliseFreezesTheFigures, which ends before L191).
  • No production edit: this pins the migration’s tax_return_line_source_kind_known and return.go L544’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.go
git 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.go L14 (labels map).
  • Modify internal/platform/printing/documents.go L25-29 (struct and comment), L56-58, L70, L77-80.
  • Modify internal/platform/printing/layout.go L28 (comment only).
  • Modify internal/modules/receivables/pdf.go L72.
  • 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-only printing.Title.

  • Consumes receivables.KindDebitNote (alias p.KindDebitNote in pdf.go), printing.RenderInvoice, printing.StatutoryInvoiceFields, printing.Default.

  • A debit note prints due date and payment terms like an invoice (!v.CreditNote guards 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 = title

Append 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 error unknown field DebitNote in struct literal.

  • Step 3: implement. render.go L14: "invoice": {"Invoice", "Invois"}, "credit_note": {"Credit note", "Nota kredit"}, "debit_note": {"Debit note", "Nota debit"},. documents.go struct (after CreditNote 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: the pdf.go mapping (DebitNote: d.Kind == p.KindDebitNote) is compile-checked only; renderDocument returns opaque PDF bytes, so nothing asserts the title came out. If a reviewer wants it pinned, extract printing.Title use 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.go
git 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.go L35-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.go L38, L41 (exact counts), append test.
  • No edit to attach.go: L134 loops ArchiveSalesKinds, so archive-read permission follows (finding 5).

Interfaces:

  • Produces ArchiveSalesKinds including "customer_debit_note", ArchivePurchaseKinds including "supplier_debit_note"; file type debit_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 to map[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, receipt problem 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 via testdb.Start).

  • Step 5: commit.

git add internal/platform/imports/archive.go internal/platform/imports/archive_test.go
git 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 imports receivables, receivablestest).
  • No production edit: catalog.go L86-95 maps receivables.EventDocumentIssued to sales_document.issued with salesDocument(ctx, tx, e.DocumentID, e.Kind) (L154), so the resource kind is whatever the document’s kind is. api.WebhookResource.kind is a free string (api/openapi.yaml L15491), 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 because KindDebitNote/the graph is missing). If webhook_event.payload stores the data under another key, adjust the two JSON paths to the shape asserted at recorder_test.go L90-99 (it unmarshals payload directly into the data struct, so number and resource.kind are top-level).

  • Step 3: commit.

git add internal/platform/webhooks/recorder_test.go
git commit -m "webhooks: pin that a debit note passes through as a sales document
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Section titled “Task 27: EX-10 — cmd/worker links the debit-note rules and guards”

Files:

  • Modify cmd/worker/wiring_test.go L160 (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.go L16 edit (drop receivables.KindDebitNote): go test ./cmd/worker/ -run TestWorkerRegistersTheEInvoiceGuards must print no 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.go
git 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 item 11a.. (The file has uncommitted edits in the working tree; git add -p only 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 output
like the rest, and are **not** validated against LHDN's sandbox or SDK samples: whether these types
carry `BillingReference` is unconfirmed. See ROADMAP open item 11a.
  • Step 3: verify. grep -n "11a\." docs/ROADMAP.md prints one line; grep -c "ROADMAP open item 11a" internal/platform/countrypack/my/myinvois/testdata/README.md prints 1. No test.

  • Step 4: commit.

git add -p docs/ROADMAP.md
git add internal/platform/countrypack/my/myinvois/testdata/README.md
git commit -m "Roadmap: debit note types 03 and 13 await sandbox validation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

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.

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.


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):

  1. AP supplier debit notes have NO reason field (purchase_invoice has no reason column; the API plan does not add one). Only the AR customer debit note carries an optional reason.
  2. GET /receivables/invoices and GET /payables/invoices accept ONE kind per call (spec: “the three inline kind query parameters” are enums). So the open-document pickers ask once per kind.
  3. GET /receivables/matchable-lines and GET /payables/matchable-lines are not extended (spec: debit notes are not delivery/GRN matched in v1), so the dialogs must not call them for a debit note.
  4. No web change is needed for e-Invoice (web/src/features/einvoice renders no document type or kind text; source_kind strings appear only on the accounting entry drawer as raw <code>), printing (Go templates), or webhooks. imports.json summary.credit_notes is a count label fed by the Go import summary; add a debit_notes label only if the imports plan adds that count.
  5. 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.json and payables.json invoices.subtitle (“… and credit notes …”) are left as is.

Conventions used below:

  • Vitest command: cd /Users/limziyang/Documents/repos/mcomtask/web && npx vitest run <file> (script test = 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.ts fails 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 (invoiceSearch enum)
  • 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', ...) in web/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.ts Expected: page test fails with Unable 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:79 becomes: kind: z.enum(['sales_invoice', 'customer_credit_note', 'customer_debit_note']).catch('sales_invoice'),
    • InvoicesPage.tsx:114 becomes: items={(['sales_invoice', 'customer_credit_note', 'customer_debit_note'] as const).map((k) => ({
    • en/receivables.json lines 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.json lines 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.json lines 75-77 become the same shape with values "发票", "贷项通知单", "借项通知单"; "新建发票", "新建贷项通知单", "新建借项通知单"; "销售发票", "贷项通知单", "借项通知单".

  • Run npx prettier --write src/lib/i18n/debitNoteKeys.test.ts (the at helper 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.ts Expected: 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.ts
git 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; isCN at line 71; useMatchableSalesLines at line 114; useIssuedInvoices at 116-118; pickOriginal at 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 (extend PATHS)
  • Test: web/src/features/receivables/__tests__/InvoiceDialog.test.tsx (pattern at 149-185; mockApi at 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.ts Expected: first new test fails Unable 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';) add const NO_ORIGINAL = '__no_original';
    • replace line 71 with:
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.fields additions (after "reason"; add a comma to the reason line):

    • 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": "借记原因"
  • 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.ts Expected: all pass, including the existing credit-note tests (labels “Invoice being corrected”, “Reason for the credit” and the absent Currency field are unchanged for customer_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.ts
git 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 by invoices.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.tsx Expected: first test fails Unable to find an element with the text: /Reason for the debit/ (title assertion passes after WEB-1; if the drawer is not exposed as role dialog named by its title, change the first assertion to findByText('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.tsx
git 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.tsx Expected: new test fails Unable to find a label with the text of: Apply DN-2026-0001 (and asked would be only ['sales_invoice']); existing tests still pass.

  • Step 3: implement. In api.ts replace useOpenInvoices (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/receivables Expected: 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.ts
git 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 (extend PATHS)
  • Test: web/src/features/payables/__tests__/InvoicesPage.test.tsx (tab test at 301-310; mockApi at 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.ts Expected: 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"; fields gains (after originalInvoice, 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); fields gains (after line 121) "originalDebit": "原发票(可选)", "noOriginal": "无原发票".

  • Run npx prettier --write on the three payables JSON files and debitNoteKeys.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.ts
git 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; useMatchableLines at 95; originals query 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.tsx Expected: first test fails Unable 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:
{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 UNMATCHED constant (grep UNMATCHED =): const NO_ORIGINAL = '__no_original';

  • Step 4: run, see green. npx vitest run src/features/payables/__tests__/InvoicesPage.test.tsx Expected: all pass, including the existing self-billed credit-note tests (picker label “Original self-billed invoice” unchanged; the state=approved assertion at line ~513 still holds).

  • Step 5: commit.

git add web/src/features/payables/InvoiceDialog.tsx web/src/features/payables/__tests__/InvoicesPage.test.tsx
git 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.tsx add import { 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 is invoices: [ then { id: 'i1', reference: 'PI-2026-0001', supplier_invoice_no: 'INV-1') with http.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 ), new http.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); insert if (q.get('kind') === 'supplier_debit_note') return HttpResponse.json({ invoices: [], next_cursor: null }); (the purchase_invoice pages stay queries[0], asserted at lines 217-220).

    New test, inside the same describe (needs openInvoice and bankAccounts, in scope there), after the I3 test:

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.tsx Expected: only the new test fails (Unable to find a label with the text of: Pay SDN-2026-0001; asked is ['purchase_invoice']); all edited existing tests still pass.

  • Step 3: implement. Replace useOpenInvoices in payables/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/payables Expected: all payables tests pass.

  • Step 5: commit.

git add web/src/features/payables/api.ts web/src/features/payables/__tests__/PaymentsPage.test.tsx
git 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 by web/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 by web/src/features/imports/ArchiveList.tsx:48,140)
  • Modify web/src/lib/i18n/debitNoteKeys.test.ts (extend PATHS)

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 PATHS in debitNoteKeys.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.ts Expected: each locale fails with trade:invoiceKinds.customer_debit_note, trade:invoiceKinds.supplier_debit_note, imports:archive.kinds.customer_debit_note, imports:archive.kinds.supplier_debit_note missing.

  • Step 3: implement. Add (commas as needed) to each invoiceKinds and archive.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 "借项通知单".
  • 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.ts
git 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.ts is not edited; the user’s modified seed.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 per e2e/README.md: npx playwright test e2e/debit-notes.spec.ts. Expected before WEB-1/2/4: fails at getByRole('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 whose aging.go still treats non-sales_invoice as a credit (spec: “a debit note ages positive; fails today”): the presence assertion getByText(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.ts
git 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 in docs/review/README.md: columns file,key,english,current,notes_blank; file is the locale-relative path such as ms/receivables.json; notes_blank left 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 | xxd and the same for zh (expect 0a; if not, printf '\n' >> file first).

  • 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/web
npx tsc --noEmit -p tsconfig.json
npx vitest run src/features/receivables src/features/payables src/features/trade src/features/imports src/lib/i18n src/drift
npx eslint src/features/receivables src/features/payables src/test/msw/forKind.ts src/lib/i18n/debitNoteKeys.test.ts e2e/debit-notes.spec.ts
npx prettier --check src e2e/debit-notes.spec.ts

Expected: 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.csv
git commit -m "Review sheets: ms and zh debit note strings awaiting native review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

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 贷记原因).

  • WEB-4/WEB-7 depend on assumption 2 (single kind per list call); if the API plan allows several, the helper and the loop collapse to one call and the forKind fixture edits are unnecessary.
  • invoices.subtitle copy (“… 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 use Edit on 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 (:57 same 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 sandbox

Tick 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 gains
the `customer_debit_note` and `supplier_debit_note` workflow kinds (a new seeded kind reaches an
existing organization only that way; see "Wave 3 base"). Until then an organization cannot create
a debit note. MyInvois types `03`/`13` are not sandbox-validated: leave debit-note e-invoicing
disabled 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

Terminal window
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.md
git 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.)