Skip to content

SST-02 Return Generation 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: An SST-registered organization records its registration (effective date, first taxable period, period length, SST payable account, SST number), sees its taxable periods with due dates, prepares each period’s return computed from the sales documents’ frozen tax snapshots (sales tax by rate on the document date, service tax by rate as it is paid or after 12 months, exempt and out-of-scope sales, manual adjustments with reasons), finalises it into an append-only frozen record (amendments are new versions; a filed period that no longer matches is flagged stale), exports a figures sheet (CSV/XLSX/PDF) for manual entry on MySST, and pays the tax through its own approved workflow kind that posts Dr SST payable / Cr bank.

Architecture: A generic engine and pack data. internal/platform/countrypack gains TaxReturnDef and the optional ReturnPack interface; countrypack/my/taxreturn.go carries the SST-02 layout. internal/platform/taxreturn (+ taxreturntest) owns registration, period arithmetic, computation, return versions, payments, the tax_return_payment seeded graph and its payout rule — it speaks of pack kinds, bases and field keys, never “SST”. internal/modules/taxreturns is the HTTP shell; reporting/export gains the tax_return sheet; the web gains web/src/features/taxreturns. accounting/ still reacts only to semantics.

Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money · React 19 · TanStack Router/Query/Table · i18next (en/ms/zh) · Vitest + MSW · Playwright

Spec: docs/superpowers/specs/2026-09-27-sst-02-design.md (D1–D16). Templates: AP (docs/superpowers/plans/2026-09-26-phase-2-ap.md, internal/platform/payables) for the payment kind; AR (docs/superpowers/plans/2026-09-27-accounts-receivable.md) for every table this plan reads. Where a step says “mirror AP”, open the named AP file and follow it line for line, changing only what the step names.

Hard dependency: AR (00310–00314, internal/platform/receivables, receivablestest) must be merged into the integration branch before Task 2. Task 1 has no database and can run any time.

Every task’s requirements implicitly include this section.

  • Migrations 00400–00409 only (declared already in internal/kernel/db/migrationranges_test.go — do not edit it). This plan uses 00400–00403.
  • Money never float. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire (OpenAPI Money); rates money.Rate / NUMERIC(19,10) / OpenAPI Rate. Compare with Cmp, never ==. No float64 in internal/platform/taxreturn, internal/modules/taxreturns, web/src/features/taxreturns (web/src/drift/nofloat.test.ts scans the web).
  • Every new table has org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, CONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite (org_id, x) foreign keys only (TestEveryTenantForeignKeyIsCompositeOnOrgID).
  • Named constraints. Every CHECK/UNIQUE/FOREIGN KEY is CONSTRAINT <table>_<rule>; no name ends in _check, _key, _excl (TestEveryConstraintIsNamed). A trigger refusal is RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on the name.
  • Pinned functions. SET search_path = pg_catalog, public, pg_temp, schema-qualified tables, no SECURITY DEFINER. The payment guard reuses public.payables_semantic(org, instance) from 00302 (it only reads an instance’s semantic; not renamed).
  • Every SQL statement names org_id = $n for each org-scoped table it touches, joins included, and every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org from tenancy.OrgFromContext; nothing takes an org argument.
  • Retention (internal/kernel/db/retention_test.go): tax_registration, tax_return, tax_return_adjustment, tax_return_payment → amendable (REVOKE DELETE); tax_return_line → appendOnly (REVOKE UPDATE, DELETE).
  • The country boundary. Nothing outside internal/platform/countrypack names SST, LHDN, RMCD or MySST in Go identifiers or SQL (spec §11). The engine uses pack_kind, basis, field keys. User-facing strings in web locale files may say “SST”; the MY pack’s Names do.
  • Accounting reacts to semantics. The payout rule is keyed (tax_return_payment, payout); no Go or SQL compares a state_key to a literal except the seeded graph and tests. taxreturn.Rules() is registered from internal/modules/taxreturns/boot.go’s init().
  • The engine reads AR tables, never writes them. Every AR column this plan names is AR’s plan’s (R1 vocabulary: sales_invoice.kind ∈ {sales_invoice, customer_credit_note}, issued_at, invoice_date, gross_amount, original_invoice_id; sales_invoice_line.withdrawn_at, tax_pack_kind, tax_rate, net_base, tax_base; customer_receipt.receipt_date; ar_allocation.receipt_id/credit_note_id/amount/effective_on/reverses_id). If AR renamed one, follow AR and note it in the task’s commit.
  • Lock order, one for the whole plan: registration FOR UPDATE (only when it is written) → return header FOR UPDATE → the period’s latest finalised return FOR UPDATE (payments) → numbering.Allocate → workflow.Transition (instance lock, posting) → audit.Write, last.
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …); only handlers open transactions; every handler whose transaction can post passes db.InTx’s error through ledger.MapError first (AP’s handlers do).
  • Business date is clock.TodayUTC(c); every DATE in Go is midnight UTC; never time.Now() in code under test.
  • Record-bearing text (amendment and discard reasons, adjustment reasons, payment bank reference) is English or Bahasa Malaysia: recordtext.Check in Go and a named CHECK (… !~ '[぀-ヿ㐀-䶿一-鿿가-힯]') in SQL.
  • Errors are RFC 7807. Handlers c.Error(apperr.New(kind, detail)) and return. A field refusal is 422 field=<name> rule=<code>: <sentence> (taxreturn.FieldError, AP’s FieldError shape copied).
  • Every route goes through httpx.Protected with a taxreturns.* (or reports.*) permission and is in api/openapi.yaml in the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces). api/public_routes.txt gains nothing. make generate after every migration, OpenAPI, sqlc.yaml or constants.yaml change; generated files committed; git status clean after it.
  • i18n. Every UI string is a key in web/src/lib/i18n/locales/{en,ms,zh}/taxreturns.json (or the named existing namespace); i18nparity.test.ts requires identical keys and placeholders. Malay is genuine Malay — use the strings in Task 8. The seeded graph carries en, ms, zh (TestEverySeededGraphShipsAllThreeLocales); the pack’s Names carry all three (countrypack.Validate).
  • Presence before absence (AGENTS.md). Every refusal test shows the accepted case first. Every guard is watched failing once: each task with a guard has a Sabotage step — break the guard, run the test, read the red message, write it into the commit body, restore.
  • Posting is process-global. A test that needs a Poster calls e.UseTaxReturnPoster(t) (Task 5), never t.Parallel().
  • Tests: testdb.Start (through receivablestest.New) per test; run only the touched packages while iterating (go test ./internal/platform/taxreturn/... -count=1); one full go test -p 2 -count=1 ./... in Task 10 only.
  • E2E on lane 8 only: ERP_STACK=erp-l8 ERP_PG_PORT=56492 ERP_MINIO_PORT=59180 ERP_API_PORT=3180 ERP_WEB_PORT=5199 (overrides already on the base). Never the user’s dev stack (55432/59000/3000/5173).
  • Never bare git stash. Set work aside with a WIP commit.
  • Git commits end with: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Plan-level choices the spec leaves open. Format: Ruling: <decision> — <why>.

  • R1 Ruling: Vocabulary — return code sst02 (MY pack); tables tax_registration, tax_return, tax_return_adjustment, tax_return_line, tax_return_payment; workflow kind = journal source_kind = taxreturn.KindPayment = "tax_return_payment"; audit entity kinds are the table names; statuses draft | finalised | discarded; bases document | payment | manual; line source kinds sales_invoice | customer_credit_note | adjustment.
  • R2 Ruling: Payment references TXP-<FY>-NNNN from numbering.Allocate(KindPayment, fiscal EndYear) at creation (AP’s rule — a withdrawn draft leaves a gap, as AP’s payments do). Returns have no number; they are named by period and version (“2026-09-01 – 2026-10-31, v2”).
  • R3 Ruling: Payment states draft, pending_approval, paid, rejected, cancelled, voided; actions submit, approve_payment, reject, revise, withdraw, void — AP’s PaymentGraph with the kind changed. Grants: paid and voided → OWNER seat.
  • R4 Ruling: A return’s figures are computed with the org’s current pack definition; a finalised return’s frozen lines carry field_key, so a later pack edit changes only labels and form references on screen, never frozen amounts.
  • R5 Ruling: Every computed figure is in sen: due(I, d) is rounded once per invoice per rate with Amount.MulDivCents; document-basis line sums are rounded per document per rate with Amount.Cents() (base amounts of a foreign invoice carry four places). Both helpers live in a new file internal/kernel/money/cents.go (AR edits rate.go; a separate file avoids the merge).
  • R6 Ruling: GET /tax-returns/{id} on a finalised return recomputes live to report stale and live_tax_payable; on a draft it computes live. No caching.
  • R7 Ruling: The registration PUT writes company_profile.sst_registration_no through company.Update (read-modify-write in the same transaction, audited by company.Update) and the registration audit row last.
  • R8 Ruling: tax_account_id default for a new registration = the single distinct account_id of the org’s active sales/both tax codes with a positive rate; if none or several, the field is required (422 tax_account). A valid tax account is active, postable, type = liability, no control_kind, no owner_module.
  • R9 Ruling: The payment copies the registration’s tax_account_id onto its own row at create/update (frozen with the draft); the payout rule debits the payment’s copy, never the registration’s live value.
  • R10 Ruling: Payment amount checks: > 0, currency is base (the column is implicit: base only), payment_date ≤ today, and amount ≤ outstanding(period) − Σ amounts of the period's other payments in pending_approval at submit; amount ≤ outstanding(period) again at approval under the lock. Outstanding counts payments whose instance semantic is payout.
  • R11 Ruling: No reconciliation check is registered with reconcile in v1; the D13 difference is shown on the return and held by a drift test (Task 5).
  • R13 Ruling: version = 1 + the highest version of any row of the period (discarded drafts keep their number, so tax_return_version_once never collides); amends_id = the period’s latest finalised version, or NULL when none — a reason is required exactly when amends_id is set.
  • R12 Ruling (web): one nav item appended to the accounting group (/tax-returns), permission taxreturns.return.read; two tabs (Periods, Payments); the return is a Drawer.

File Task Responsibility
00400_tax_return.sql 2 tax_registration, tax_return, tax_return_adjustment, tax_return_line, their guards.
00401_tax_return_payment.sql 5 tax_return_payment and its draft guard.
00402_tax_return_grants.sql 6 Existing Owner and Finance roles get the five taxreturns.* keys.
00403_report_export_tax_return.sql 7 report_export_report_known gains tax_return.
Path Task(s) Responsibility
internal/platform/countrypack/return.go (+ test) 1 TaxReturnDef, ReturnField, ReturnPack, bases, validateReturn.
internal/platform/countrypack/my/taxreturn.go (+ test) 1 The SST-02 layout.
internal/platform/taxreturn/ 1–5 doc.go, period.go, errors.go, registration.go, attribute.go, compute.go, return.go, graphs.go, payment.go, posting.go
internal/platform/taxreturn/taxreturntest/ 2 (grown in 3–5) Test world on receivablestest.Env.
internal/modules/taxreturns/ 6 permissions.go, routes.go, boot.go, http.go, errors.go, registration_handlers.go, return_handlers.go, payment_handlers.go
web/src/features/taxreturns/ 8, 9 API hooks, page, dialogs, drawer.
Path Task Change
internal/platform/countrypack/validate.go 1 Validate calls validateReturn when the pack is a ReturnPack.
internal/platform/countrypack/my/my.go 1 func (pack) TaxReturn() countrypack.TaxReturnDef { return taxReturn }.
internal/kernel/money/cents.go (+ test) 3 Cents(), MulDivCents.
internal/kernel/db/retention_test.go 2, 5 New tables into their lists.
sqlc.yaml 2 tax_return_line.rate → *money.Rate.
constants.yaml (+ generated vocab) 6 Five permissions.
internal/platform/reporting/export/{export.go,params.go,project.go} 7 tax_return, Params.ReturnID.
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) 6, 7 /tax-returns/…, export enum and param.
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go 6 Compose the module (mirror how AP’s module is composed).
web/src/app/shell/nav.ts, web/src/app/routes.ts 8 Nav item; route.
web/e2e/seed.ts, web/e2e/support.ts, web/e2e/tax-returns.spec.ts 10 Finance keys; the e2e.
docs/ROADMAP.md, docs/DEPLOY_NOTES.md 10 Tick SST-02; deploy notes.

Tasks in one wave touch disjoint files and may run in parallel (each on its own worktree, merged in task order). api/openapi.yaml and the generated clients are one serial lane (6 → 7).

Wave Tasks Why this grouping
0 1 Pack data and period arithmetic: no database, no AR — may run before AR merges.
1 2 Schema, registration, test world — after AR is merged.
2 3 Computation (needs AR documents in the test world).
3 4 Return lifecycle (needs Compute).
4 5 Payment kind, rule, drift test (needs finalised returns).
5 6 Module, OpenAPI, permissions, wiring.
6 7 ∥ 8 7: export (OpenAPI lane, platform + 00403). 8: web page, registration, periods, payments tab (needs 6’s client). Disjoint files.
7 9 Web return drawer, adjustments, finalise, payment dialog (needs 7’s export enum).
8 10 E2E, roadmap, deploy notes, full suite.

Task 1: The pack’s return definition, the SST-02 layout, period arithmetic

Section titled “Task 1: The pack’s return definition, the SST-02 layout, period arithmetic”

Files:

  • Create: internal/platform/countrypack/return.go, internal/platform/countrypack/return_test.go
  • Modify: internal/platform/countrypack/validate.go
  • Create: internal/platform/countrypack/my/taxreturn.go, internal/platform/countrypack/my/taxreturn_test.go
  • Modify: internal/platform/countrypack/my/my.go
  • Create: internal/platform/taxreturn/doc.go, internal/platform/taxreturn/period.go, internal/platform/taxreturn/period_test.go

Interfaces:

  • Produces: countrypack.TaxReturnDef{Code; Names; PeriodMonths, DueMonthsAfterEnd int; FormRefsVerified bool; SheetNote string; Fields []ReturnField}, countrypack.ReturnField{Key, PackKind, Basis string; PaymentLimitMonths, Sign int; ByRate bool; FormRef string; Names}, countrypack.ReturnPack, countrypack.BasisDocument|BasisPayment|BasisManual, (TaxReturnDef).Field(key) (ReturnField, bool), (TaxReturnDef).FieldForKind(packKind) (ReturnField, bool); taxreturn.Period{Start, End, DueOn time.Time}, taxreturn.Periods(effectiveFrom, firstEnd time.Time, months, dueMonths int, through time.Time) []Period, taxreturn.PeriodStarting(...same..., start time.Time) (Period, bool).

  • Step 1: Failing tests. return_test.go (package countrypack, uses the file’s own testPack helpers from testpack_test.go): a valid def passes; each refusal after it — duplicate key, unknown basis, payment basis with PaymentLimitMonths ≤ 0, manual field with a PackKind, document/payment field without one, two fields on one pack kind, Sign outside {−1, 0, 1}, PeriodMonths outside 1–12, DueMonthsAfterEnd < 0, a missing zh name. my/taxreturn_test.go: countrypack.Validate(pack{}) passes; the def has the seven keys of spec D1 in order; FieldForKind("sales_tax").Basis == "document"; FieldForKind("service_tax").PaymentLimitMonths == 12; every positive-rate sales code of the pack (ST10 ST5 SVT6 SVT8) and EXEMPT, OS has a field for its PackKind (the pack cannot seed a code its return drops). period_test.go:

func d(s string) time.Time { t, _ := time.Parse("2006-01-02", s); return t }
func TestPeriodsFollowTheRegistrationsCycle(t *testing.T) {
got := taxreturn.Periods(d("2026-01-15"), d("2026-02-28"), 2, 1, d("2026-09-21"))
require.Equal(t, []taxreturn.Period{
{Start: d("2026-01-15"), End: d("2026-02-28"), DueOn: d("2026-03-31")},
{Start: d("2026-03-01"), End: d("2026-04-30"), DueOn: d("2026-05-31")},
{Start: d("2026-05-01"), End: d("2026-06-30"), DueOn: d("2026-07-31")},
{Start: d("2026-07-01"), End: d("2026-08-31"), DueOn: d("2026-09-30")},
{Start: d("2026-09-01"), End: d("2026-10-31"), DueOn: d("2026-11-30")},
}, got)
}
func TestDueDatesCrossYearEndAndFebruary(t *testing.T) {
got := taxreturn.Periods(d("2027-11-01"), d("2027-12-31"), 2, 1, d("2028-01-05"))
require.Equal(t, d("2028-01-31"), got[0].DueOn)
one := taxreturn.Periods(d("2028-01-01"), d("2028-01-31"), 1, 1, d("2028-01-31"))
require.Equal(t, d("2028-02-29"), one[0].DueOn) // leap year
three := taxreturn.Periods(d("2026-01-01"), d("2026-03-31"), 3, 1, d("2026-04-01"))
require.Equal(t, d("2026-06-30"), three[1].End)
}
func TestPeriodStartingAnswersOnlyARealStart(t *testing.T) {
_, ok := taxreturn.PeriodStarting(d("2026-01-15"), d("2026-02-28"), 2, 1, d("2026-03-01"))
require.True(t, ok) // presence
_, ok = taxreturn.PeriodStarting(d("2026-01-15"), d("2026-02-28"), 2, 1, d("2026-03-02"))
require.False(t, ok)
}
  • Step 2: Run — expect failure (undefined: countrypack.TaxReturnDef, undefined: taxreturn.Periods).

  • Step 3: Implement return.go.

package countrypack
import (
"fmt"
"strings"
)
// Bases a return field is computed on (spec D1, D4, D5, D9).
const (
BasisDocument = "document" // lines of issued documents dated in the period
BasisPayment = "payment" // invoice lines as they are paid, or in full PaymentLimitMonths after the invoice
BasisManual = "manual" // entered by the preparer with a reason
)
// ReturnField is one line of a periodic indirect-tax return. The engine in
// internal/platform/taxreturn gives Basis, Sign and ByRate their meaning; the
// country's own vocabulary lives only in Key, PackKind, FormRef and Names.
type ReturnField struct {
Key string
PackKind string // the tax_code.pack_kind whose line snapshots feed it; "" for manual
Basis string
PaymentLimitMonths int // payment basis only: due in full this many months after the invoice
Sign int // +1 adds to tax payable, -1 deducts, 0 information only
ByRate bool // one row per tax rate
FormRef string
Names Names
}
// TaxReturnDef is a country's periodic indirect-tax return (spec §11's
// StatutoryReports/FilingPeriods, narrowed to what the engine needs).
type TaxReturnDef struct {
Code string
Names Names
PeriodMonths int // default period length for a new registration
DueMonthsAfterEnd int // due on the last day of the month this many months after the period ends
FormRefsVerified bool
SheetNote string // printed at the foot of the figures sheet (spec D12)
Fields []ReturnField
}
// ReturnPack is implemented by a Pack whose country has such a return.
type ReturnPack interface{ TaxReturn() TaxReturnDef }
func (d TaxReturnDef) Field(key string) (ReturnField, bool) {
for _, f := range d.Fields {
if f.Key == key {
return f, true
}
}
return ReturnField{}, false
}
func (d TaxReturnDef) FieldForKind(packKind string) (ReturnField, bool) {
for _, f := range d.Fields {
if f.PackKind != "" && f.PackKind == packKind {
return f, true
}
}
return ReturnField{}, false
}
func validateReturn(d TaxReturnDef) error {
bad := func(format string, a ...any) error {
return fmt.Errorf("%w: tax return %q: %s", ErrInvalidPack, d.Code, fmt.Sprintf(format, a...))
}
if strings.TrimSpace(d.Code) == "" || strings.TrimSpace(d.SheetNote) == "" || d.PeriodMonths < 1 || d.PeriodMonths > 12 || d.DueMonthsAfterEnd < 0 {
return bad("code, sheet note, period months 1–12 and due months ≥ 0 are required")
}
if err := requireNames(d.Names, "tax return "+d.Code); err != nil {
return err
}
keys, kinds := map[string]bool{}, map[string]bool{}
for _, f := range d.Fields {
switch {
case f.Key == "" || keys[f.Key]:
return bad("field key %q is blank or repeated", f.Key)
case f.Basis != BasisDocument && f.Basis != BasisPayment && f.Basis != BasisManual:
return bad("field %q has unknown basis %q", f.Key, f.Basis)
case (f.Basis == BasisManual) != (f.PackKind == ""):
return bad("field %q: a manual field names no pack kind, every other field names one", f.Key)
case f.PackKind != "" && kinds[f.PackKind]:
return bad("pack kind %q feeds two fields", f.PackKind)
case (f.Basis == BasisPayment) != (f.PaymentLimitMonths > 0):
return bad("field %q: a payment limit is required on, and only on, a payment-basis field", f.Key)
case f.Sign < -1 || f.Sign > 1:
return bad("field %q: sign must be -1, 0 or 1", f.Key)
}
if err := requireNames(f.Names, "tax return field "+f.Key); err != nil {
return err
}
keys[f.Key], kinds[f.PackKind] = true, f.PackKind != ""
}
return nil
}

(kinds[f.PackKind] = f.PackKind != "" keeps "" from ever tripping the repeat check.) In validate.go’s Validate, after the tax codes: if rp, ok := p.(ReturnPack); ok { if err := validateReturn(rp.TaxReturn()); err != nil { return err } }. Adjust ErrInvalidPack/requireNames to the names validate.go actually exports.

  • Step 4: Implement my/taxreturn.go (and add func (pack) TaxReturn() countrypack.TaxReturnDef { return taxReturn } to my.go):
package my
import "github.com/mcom/erp/internal/platform/countrypack"
// taxReturn is the SST-02 return (spec D1).
//
// FLAG FOR REVIEW: the taxable period (two months), the due date (last day
// of the following month), the payment basis of service tax with its
// 12-month limit, and every FormRef below are written from general
// knowledge of the Sales Tax Act 2018, the Service Tax Act 2018 and RMCD
// practice, NOT verified against the Acts or the current SST-02 form.
// FormRefsVerified stays false until a tax agent has mapped each field to
// the form's part and item number; the figures sheet prints "(unverified)"
// beside every reference while it is false.
var taxReturn = countrypack.TaxReturnDef{
Code: "sst02",
Names: names("SST-02 return", "Penyata SST-02", "SST-02 申报表"),
PeriodMonths: 2,
DueMonthsAfterEnd: 1,
FormRefsVerified: false,
SheetNote: "Enter these figures on MySST; this file is not an upload format.",
Fields: []countrypack.ReturnField{
{Key: "sales_tax", PackKind: "sales_tax", Basis: countrypack.BasisDocument, Sign: 1, ByRate: true,
FormRef: "Part B — sales tax",
Names: names("Taxable goods sold", "Barang bercukai yang dijual", "应税商品销售")},
{Key: "service_tax", PackKind: "service_tax", Basis: countrypack.BasisPayment, PaymentLimitMonths: 12, Sign: 1, ByRate: true,
FormRef: "Part C — service tax",
Names: names("Taxable services provided (due when paid, or after 12 months)",
"Perkhidmatan bercukai yang disediakan (kena dibayar apabila bayaran diterima, atau selepas 12 bulan)",
"应税服务(收款时或 12 个月后应缴)")},
{Key: "imported_services", Basis: countrypack.BasisManual, Sign: 1,
FormRef: "Part C — imported taxable services",
Names: names("Imported taxable services", "Perkhidmatan bercukai yang diimport", "进口应税服务")},
{Key: "other_payable", Basis: countrypack.BasisManual, Sign: 1,
FormRef: "Part B/C — other tax payable",
Names: names("Other tax payable (own use, disposal)", "Cukai lain kena dibayar (kegunaan sendiri, pelupusan)", "其他应缴税款(自用、处置)")},
{Key: "deduction", Basis: countrypack.BasisManual, Sign: -1,
FormRef: "Part B/C — deductions",
Names: names("Deductions (bad-debt relief, other)", "Potongan (pelepasan hutang lapuk, lain-lain)", "扣减(坏账减免等)")},
{Key: "exempt", PackKind: "exempt", Basis: countrypack.BasisDocument, Sign: 0,
FormRef: "Part D — exempt goods and services",
Names: names("Exempt sales", "Jualan dikecualikan", "豁免销售")},
{Key: "out_of_scope", PackKind: "out_of_scope", Basis: countrypack.BasisDocument, Sign: 0,
FormRef: "",
Names: names("Out of scope (not reported on the form)", "Di luar skop (tidak dilaporkan dalam borang)", "不适用(不在表格申报)")},
},
}

The pack’s purchase codes (PST10 … PNT) share pack kinds with the sales codes; the engine reads only sales documents, so they never reach a field (spec D6). The nosst_test.go positive control still reads my.go.

  • Step 5: Implement taxreturn/doc.go and period.go. doc.go: the package comment (the engine; names no country; reads AR tables read-only; spec D1). period.go:
package taxreturn
import "time"
// Period is one taxable period (spec D3). Every date is midnight UTC.
type Period struct{ Start, End, DueOn time.Time }
func monthEnd(y int, m time.Month) time.Time { return time.Date(y, m+1, 0, 0, 0, 0, 0, time.UTC) }
// Periods lists the taxable periods from the first through the one
// containing through: period 0 is [effectiveFrom, firstEnd]; each next one
// starts the day after and ends at the month end months later; each is due
// on the last day of the month dueMonths after it ends.
func Periods(effectiveFrom, firstEnd time.Time, months, dueMonths int, through time.Time) []Period {
var out []Period
for start, end := effectiveFrom, firstEnd; !start.After(through); {
out = append(out, Period{Start: start, End: end, DueOn: monthEnd(end.Year(), end.Month()+time.Month(dueMonths))})
start = end.AddDate(0, 0, 1) // the 1st of a month: AddDate is safe from here
end = monthEnd(start.Year(), start.Month()+time.Month(months-1))
}
return out
}
// PeriodStarting answers the period that starts on start, if one does.
func PeriodStarting(effectiveFrom, firstEnd time.Time, months, dueMonths int, start time.Time) (Period, bool) {
ps := Periods(effectiveFrom, firstEnd, months, dueMonths, start)
if len(ps) > 0 && ps[len(ps)-1].Start.Equal(start) {
return ps[len(ps)-1], true
}
return Period{}, false
}
  • Step 6: Run — expect PASS. go test ./internal/platform/countrypack/... ./internal/platform/taxreturn/... -count=1.

  • Step 7: Sabotage. (a) Delete the exempt field from taxReturn: TestEverySeededSalesCodeHasAReturnField red naming EXEMPT. (b) Change end.Month()+time.Month(dueMonths) to end.Month()+time.Month(dueMonths)+1: TestPeriodsFollowTheRegistrationsCycle red. Restore; record both messages.

  • Step 8: Commit. SST-02 Task 1: pack return definition, SST-02 layout, taxable periods


Task 2: Schema, registration service, test world

Section titled “Task 2: Schema, registration service, test world”

Files:

  • Create: db/migrations/00400_tax_return.sql
  • Modify: internal/kernel/db/retention_test.go, sqlc.yaml
  • Create: internal/platform/taxreturn/errors.go, internal/platform/taxreturn/registration.go, internal/platform/taxreturn/registration_test.go, internal/platform/taxreturn/schema_test.go
  • Create: internal/platform/taxreturn/taxreturntest/env.go

Interfaces:

  • Consumes: receivablestest.New, company.Get/Update, chart.GetAccount, chart.ListTaxCodes, countrypack.Lookup.

  • Produces: tables of spec §“Data model” (00400); Registration{ID; ReturnCode; EffectiveFrom, FirstPeriodEnd time.Time; PeriodMonths int; TaxAccountID uuid.UUID; RegistrationNo string; Revision int; Frozen bool}, RegistrationInput{EffectiveFrom, FirstPeriodEnd time.Time; PeriodMonths int; TaxAccountID *uuid.UUID; RegistrationNo string; Revision int}; Definition(ctx, tx) (countrypack.TaxReturnDef, error); GetRegistration(ctx, tx) (Registration, error); PutRegistration(ctx, tx, c, actor, in) (Registration, error); (Registration).Periods(def, through); errors ErrNoTaxReturn, ErrNoRegistration, ErrRegistrationFrozen, ErrStaleRevision, FieldError{Field, Rule, Detail}; test world taxreturntest.Env with New(t), e.Register(t, from, firstEnd string), e.At(t, day string).

  • Step 1: Failing schema test schema_test.go (raw SQL as erp_app through the world’s tx helpers, the AP invoice_schema_test.go pattern):

func TestTheReturnFreezesOnceFinalised(t *testing.T) {
e := taxreturntest.New(t)
reg := e.Register(t, "2026-01-01", "2026-02-28")
id := e.RawDraft(t, reg, "2026-07-01", "2026-08-31") // INSERT as erp_app, status draft
// PRESENCE: a draft takes an adjustment, a line and an update.
require.NoError(t, e.Exec(t, `INSERT INTO tax_return_adjustment (id, org_id, return_id, field_key, taxable_amount, tax_amount, reason, created_by, created_at)
VALUES (gen_random_uuid(), $1, $2, 'imported_services', 100, 8, 'AWS invoice', $3, now())`, e.Org, id, e.Staff))
require.NoError(t, e.Exec(t, `INSERT INTO tax_return_line (org_id, return_id, field_key, source_kind, reference, taxable_amount, tax_amount, adjustment_id)
SELECT $1, $2, 'imported_services', 'adjustment', 'ADJ', 100, 8, a.id FROM tax_return_adjustment a WHERE a.org_id = $1 AND a.return_id = $2`, e.Org, id))
require.NoError(t, e.Exec(t, `UPDATE tax_return SET updated_at = now() WHERE org_id = $1 AND id = $2`, e.Org, id))
e.RawFinalise(t, id) // UPDATE status/registration_no/total_tax/tax_payable/finalised_by/at
// ABSENCE: finalised, every write is refused by name.
requirePgError(t, e.Exec(t, `UPDATE tax_return SET updated_at = now() WHERE org_id = $1 AND id = $2`, e.Org, id), "23514", "tax_return_frozen")
requirePgError(t, e.Exec(t, `INSERT INTO tax_return_adjustment (id, org_id, return_id, field_key, taxable_amount, tax_amount, reason, created_by, created_at)
VALUES (gen_random_uuid(), $1, $2, 'deduction', 0, 1, 'late', $3, now())`, e.Org, id, e.Staff), "23514", "tax_return_adjustment_draft_only")
requirePgError(t, e.Exec(t, `INSERT INTO tax_return_line (org_id, return_id, field_key, source_kind, reference, taxable_amount, tax_amount, adjustment_id)
SELECT $1, $2, 'deduction', 'adjustment', 'X', 0, 1, a.id FROM tax_return_adjustment a WHERE a.org_id = $1 AND a.return_id = $2`, e.Org, id), "23514", "tax_return_line_draft_only")
requirePgError(t, e.Exec(t, `UPDATE tax_return_line SET tax_amount = 9 WHERE org_id = $1`, e.Org), "42501", "")
requirePgError(t, e.Exec(t, `DELETE FROM tax_return_line WHERE org_id = $1`, e.Org), "42501", "")
requirePgError(t, e.Exec(t, `UPDATE tax_registration SET period_months = 1, first_period_end = '2026-01-31' WHERE org_id = $1`, e.Org),
"23514", "tax_registration_period_frozen")
}

plus TestTheRegistrationShapeIsChecked (presence: a valid row inserts; then first_period_end not a month end → tax_registration_first_period_month_end; before effective_from → …_after_start; 2026-01-01/2026-03-31 with 2 months → …_within; period_months = 13 → …_period_months_range), and TestOneDraftPerPeriod (second draft → unique tax_return_one_draft; presence: after the first is discarded a new draft inserts). Retention: add the five tables to retention_test.go lists (spec D16).

  • Step 2: Run — expect failure (relation tax_registration does not exist).

  • Step 3: The migration 00400_tax_return.sql:

-- +goose Up
-- THE REGISTRATION (SST-02 spec D2). One per organization per return code.
-- The registration number itself lives on company_profile.sst_registration_no
-- (the field invoices snapshot), so invoices and returns cannot disagree.
CREATE TABLE tax_registration (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
return_code TEXT NOT NULL CONSTRAINT tax_registration_return_code_shape CHECK (return_code ~ '^[a-z0-9_]{1,32}$'),
effective_from DATE NOT NULL,
first_period_end DATE NOT NULL,
period_months INT NOT NULL CONSTRAINT tax_registration_period_months_range CHECK (period_months BETWEEN 1 AND 12),
tax_account_id UUID NOT NULL,
revision INT NOT NULL DEFAULT 1 CONSTRAINT tax_registration_revision_positive CHECK (revision >= 1),
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT tax_registration_tenant_id UNIQUE (org_id, id),
CONSTRAINT tax_registration_once UNIQUE (org_id, return_code),
CONSTRAINT tax_registration_first_period_month_end
CHECK (first_period_end = (date_trunc('month', first_period_end) + interval '1 month - 1 day')::date),
CONSTRAINT tax_registration_first_period_after_start CHECK (first_period_end >= effective_from),
CONSTRAINT tax_registration_first_period_within
CHECK (first_period_end < (effective_from + make_interval(months => period_months))::date),
CONSTRAINT tax_registration_tax_account_fk FOREIGN KEY (org_id, tax_account_id) REFERENCES account (org_id, id),
CONSTRAINT tax_registration_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id)
);
-- THE RETURN (D7): one row per version of a period. A draft stores only its
-- adjustments; finalising freezes the figures into tax_return_line.
CREATE TABLE tax_return (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
registration_id UUID NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
due_on DATE NOT NULL,
version INT NOT NULL CONSTRAINT tax_return_version_positive CHECK (version >= 1),
status TEXT NOT NULL DEFAULT 'draft' CONSTRAINT tax_return_status_known CHECK (status IN ('draft','finalised','discarded')),
amends_id UUID,
amendment_reason TEXT,
registration_no TEXT,
total_tax NUMERIC(19,4),
tax_payable NUMERIC(19,4),
prepared_by UUID NOT NULL,
finalised_by UUID,
finalised_at TIMESTAMPTZ,
discarded_at TIMESTAMPTZ,
discard_reason TEXT,
revision INT NOT NULL DEFAULT 1 CONSTRAINT tax_return_revision_positive CHECK (revision >= 1),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT tax_return_tenant_id UNIQUE (org_id, id),
CONSTRAINT tax_return_version_once UNIQUE (org_id, registration_id, period_start, version),
CONSTRAINT tax_return_period_order CHECK (period_end >= period_start AND due_on > period_end),
-- An amendment names the finalised version it amends and says why. version
-- counts every row of the period, discarded drafts included (Ruling R13).
CONSTRAINT tax_return_amendment_shape CHECK ((amends_id IS NULL) = (amendment_reason IS NULL)
AND btrim(coalesce(amendment_reason, 'x')) <> ''),
CONSTRAINT tax_return_finalised_complete CHECK (
(status = 'finalised') = (finalised_at IS NOT NULL)
AND (finalised_at IS NULL) = (finalised_by IS NULL) AND (finalised_at IS NULL) = (registration_no IS NULL)
AND (finalised_at IS NULL) = (total_tax IS NULL) AND (finalised_at IS NULL) = (tax_payable IS NULL)),
CONSTRAINT tax_return_discarded_complete CHECK ((status = 'discarded') = (discarded_at IS NOT NULL)
AND (discarded_at IS NULL) = (discard_reason IS NULL)),
CONSTRAINT tax_return_text_length CHECK (length(coalesce(amendment_reason, '')) <= 500 AND length(coalesce(discard_reason, '')) <= 500
AND length(coalesce(registration_no, '')) <= 40),
CONSTRAINT tax_return_record_language CHECK (coalesce(amendment_reason, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'
AND coalesce(discard_reason, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT tax_return_registration_fk FOREIGN KEY (org_id, registration_id) REFERENCES tax_registration (org_id, id),
CONSTRAINT tax_return_amends_fk FOREIGN KEY (org_id, amends_id) REFERENCES tax_return (org_id, id),
CONSTRAINT tax_return_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id),
CONSTRAINT tax_return_finalised_by_fk FOREIGN KEY (org_id, finalised_by) REFERENCES organization_member (org_id, id)
);
CREATE UNIQUE INDEX tax_return_one_draft ON tax_return (org_id, registration_id, period_start) WHERE status = 'draft';
CREATE INDEX tax_return_by_period ON tax_return (org_id, registration_id, period_start, version DESC);
CREATE TABLE tax_return_adjustment (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
return_id UUID NOT NULL,
field_key TEXT NOT NULL CONSTRAINT tax_return_adjustment_field_key_shape CHECK (field_key ~ '^[a-z0-9_]{1,40}$'),
taxable_amount NUMERIC(19,4) NOT NULL CONSTRAINT tax_return_adjustment_taxable_nonnegative CHECK (taxable_amount >= 0),
tax_amount NUMERIC(19,4) NOT NULL CONSTRAINT tax_return_adjustment_tax_positive CHECK (tax_amount > 0),
reason TEXT NOT NULL CONSTRAINT tax_return_adjustment_reason_shape CHECK (btrim(reason) <> '' AND length(reason) <= 500
AND reason !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
withdrawn_at TIMESTAMPTZ,
CONSTRAINT tax_return_adjustment_tenant_id UNIQUE (org_id, id),
CONSTRAINT tax_return_adjustment_cents CHECK (taxable_amount = round(taxable_amount, 2) AND tax_amount = round(tax_amount, 2)),
CONSTRAINT tax_return_adjustment_return_fk FOREIGN KEY (org_id, return_id) REFERENCES tax_return (org_id, id),
CONSTRAINT tax_return_adjustment_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id)
);
-- THE FROZEN FIGURES: one row per field, rate and source. Append-only.
CREATE TABLE tax_return_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
return_id UUID NOT NULL,
field_key TEXT NOT NULL CONSTRAINT tax_return_line_field_key_shape CHECK (field_key ~ '^[a-z0-9_]{1,40}$'),
rate NUMERIC(19,10),
source_kind TEXT NOT NULL CONSTRAINT tax_return_line_source_kind_known
CHECK (source_kind IN ('sales_invoice','customer_credit_note','adjustment')),
document_id UUID,
adjustment_id UUID,
reference TEXT NOT NULL,
document_date DATE,
taxable_amount NUMERIC(19,4) NOT NULL,
tax_amount NUMERIC(19,4) NOT NULL,
CONSTRAINT tax_return_line_tenant_id UNIQUE (org_id, id),
CONSTRAINT tax_return_line_source_shape CHECK ((source_kind = 'adjustment') = (adjustment_id IS NOT NULL)
AND (source_kind = 'adjustment') = (document_id IS NULL)),
CONSTRAINT tax_return_line_return_fk FOREIGN KEY (org_id, return_id) REFERENCES tax_return (org_id, id),
CONSTRAINT tax_return_line_document_fk FOREIGN KEY (org_id, document_id) REFERENCES sales_invoice (org_id, id),
CONSTRAINT tax_return_line_adjustment_fk FOREIGN KEY (org_id, adjustment_id) REFERENCES tax_return_adjustment (org_id, id)
);
CREATE INDEX tax_return_line_by_return ON tax_return_line (org_id, return_id, field_key);
-- +goose StatementBegin
CREATE FUNCTION tax_return_frozen() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF OLD.status <> 'draft' THEN
RAISE EXCEPTION 'tax return % is %: it cannot change', OLD.id, OLD.status
USING ERRCODE = '23514', CONSTRAINT = 'tax_return_frozen';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER tax_return_frozen BEFORE UPDATE ON tax_return FOR EACH ROW EXECUTE FUNCTION tax_return_frozen();
-- +goose StatementBegin
CREATE FUNCTION tax_return_child_draft_only() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF (SELECT r.status FROM public.tax_return r WHERE r.org_id = NEW.org_id AND r.id = NEW.return_id) IS DISTINCT FROM 'draft' THEN
RAISE EXCEPTION 'tax return % is not a draft', NEW.return_id
USING ERRCODE = '23514', CONSTRAINT = TG_ARGV[0];
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER tax_return_adjustment_draft_only BEFORE INSERT OR UPDATE ON tax_return_adjustment
FOR EACH ROW EXECUTE FUNCTION tax_return_child_draft_only('tax_return_adjustment_draft_only');
CREATE TRIGGER tax_return_line_draft_only BEFORE INSERT ON tax_return_line
FOR EACH ROW EXECUTE FUNCTION tax_return_child_draft_only('tax_return_line_draft_only');
-- +goose StatementBegin
CREATE FUNCTION tax_registration_period_frozen() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF (NEW.effective_from, NEW.first_period_end, NEW.period_months) IS DISTINCT FROM
(OLD.effective_from, OLD.first_period_end, OLD.period_months)
AND EXISTS (SELECT 1 FROM public.tax_return r
WHERE r.org_id = OLD.org_id AND r.registration_id = OLD.id AND r.status = 'finalised') THEN
RAISE EXCEPTION 'registration % has a finalised return: its periods are fixed', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'tax_registration_period_frozen';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER tax_registration_period_frozen BEFORE UPDATE ON tax_registration
FOR EACH ROW EXECUTE FUNCTION tax_registration_period_frozen();
ALTER TABLE tax_registration ENABLE ROW LEVEL SECURITY;
ALTER TABLE tax_registration FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON tax_registration
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- … the same four statements for tax_return, tax_return_adjustment, tax_return_line …
REVOKE DELETE ON tax_registration, tax_return, tax_return_adjustment FROM erp_app;
REVOKE UPDATE, DELETE ON tax_return_line FROM erp_app;
-- +goose Down
DROP TABLE tax_return_line;
DROP TABLE tax_return_adjustment;
DROP TABLE tax_return;
DROP TABLE tax_registration;
DROP FUNCTION tax_registration_period_frozen();
DROP FUNCTION tax_return_child_draft_only();
DROP FUNCTION tax_return_frozen();

Check the grant statements against 00304’s (the app role’s name, and whether a GRANT … TO erp_app is needed for new tables or default privileges cover it — copy what 00304 does). Passing the constraint name as TG_ARGV[0] into USING CONSTRAINT = — if plpgsql refuses a non-literal there, write two functions (one per table) instead. sqlc.yaml: add - column: "tax_return_line.rate" with the pointer money.Rate form (expense_claim_line.quantity’s shape). make generate.

  • Step 4: Run schema tests — expect PASS. The world helpers RawDraft, RawFinalise, Exec live in taxreturntest/env.go (below).

  • Step 5: Failing registration tests registration_test.go:

    • TestARegistrationDefaultsFromThePackAndTheChart — PutRegistration with PeriodMonths: 0, TaxAccountID: nil, registration no W10-1808-32000001: stored with 2, the id of account 2140; company.Get now has the SST number; an audit_log row for company_profile and one for tax_registration.
    • TestTheTaxAccountMustBeALiabilityWithoutControl — presence: 2140 accepted; then 2105 (control ap_trade), 4100 (income) → 422 tax_account.
    • TestAnOrgWithoutAReturnPackHasNoTaxReturn — switch the world’s company profile country to one with no pack (the countrypack testpack pattern) → ErrNoTaxReturn; presence: MY answers the def.
    • TestRegistrationPeriodsFreezeAfterAFinalisedReturn — presence: PutRegistration changing first_period_end succeeds; e.RawFinalise a return; the same change → ErrRegistrationFrozen (mapped from tax_registration_period_frozen); changing only tax_account_id still succeeds.
    • TestAStaleRevisionIsRefused — revision 1 twice: second → ErrStaleRevision.
  • Step 6: Implement errors.go, registration.go. FieldError is AP’s (payables.FieldError) copied with its Error() format. Definition: company.Get → countrypack.Lookup(profile.CountryCode) → p.(countrypack.ReturnPack) or ErrNoTaxReturn. GetRegistration: SELECT … FROM tax_registration WHERE org_id = $1 AND return_code = $2, RegistrationNo from company.Get, Frozen = EXISTS finalised return. PutRegistration(ctx, tx, c, actor, in): validate in Go (effective_from required; first_period_end month end, ≥ from, within months — the same three rules the CHECKs enforce, as FieldErrors month_end, first_period_within; period_months 0 ⇒ def.PeriodMonths, else 1–12 period_months; registration_no 1–40 chars, ^[A-Za-z0-9-]+$ registration_no; tax account per R8), then INSERT … ON CONFLICT (org_id, return_code) DO NOTHING for a new one, or SELECT … FOR UPDATE + revision check + UPDATE … revision = revision + 1 for an existing one (map tax_registration_period_frozen → ErrRegistrationFrozen), then company.Update with the SST number changed (skip when unchanged), then audit.Write (entity tax_registration) last. (Registration).Periods(def, through) calls Periods(r.EffectiveFrom, r.FirstPeriodEnd, r.PeriodMonths, def.DueMonthsAfterEnd, through).

  • Step 7: The test world taxreturntest/env.go:

// Env is the SST-02 test world: AR's (customers, sales documents, receipts)
// plus a registration and a movable business date.
type Env struct{ *receivablestest.Env }
func New(t *testing.T) *Env { t.Helper(); return &Env{Env: receivablestest.New(t)} }
// Register records a registration effective from, first period ending
// firstEnd, two-month periods, the pack's default tax account.
func (e *Env) Register(t *testing.T, from, firstEnd string) taxreturn.Registration { … PutRegistration in e.InTx … }
// At moves the business date to day for everything the world does next —
// documents issue, receipts record and returns finalise on it. If the
// Poster registered by UseReceivablesPoster/UseTaxReturnPoster holds its own
// clock (accounting.NewPoster(e.Clock, …)), re-register it here so posting
// dates follow; check NewPoster before writing this.
func (e *Env) At(t *testing.T, day string) { … e.Clock = clock.Fixed(<day> 09:00 UTC) … }
// RawDraft/RawFinalise/Exec: raw SQL as erp_app, for the schema tests only.

Days used by this plan’s tests stay inside the fiscal year the world provisions (2026); check expensestest and extend the fiscal calendar in the world if a test needs 2027.

  • Step 8: Run — expect PASS. go test ./internal/platform/taxreturn/... ./internal/kernel/db/... -count=1.

  • Step 9: Sabotage. (a) Change IF OLD.status <> 'draft' to IF false: TestTheReturnFreezesOnceFinalised red at tax_return_frozen. (b) Remove the EXISTS guard’s condition (always pass): TestRegistrationPeriodsFreezeAfterAFinalisedReturn red. (c) Drop tax_return_line from retention_test.go’s appendOnly list: the retention test red (unruled table). Restore; record messages.

  • Step 10: Commit. SST-02 Task 2: schema, registration, test world


Task 3: Computation — document basis, payment basis, reconciliation

Section titled “Task 3: Computation — document basis, payment basis, reconciliation”

Files:

  • Create: internal/kernel/money/cents.go, internal/kernel/money/cents_test.go
  • Create: internal/platform/taxreturn/attribute.go, internal/platform/taxreturn/attribute_test.go
  • Create: internal/platform/taxreturn/compute.go, internal/platform/taxreturn/compute_test.go

Interfaces:

  • Consumes: receivablestest (e.Customer, e.Invoice, e.Issue, e.CreditNote, e.IssueCreditNote, e.Receipt, e.Record, e.TaxCode, receivablestest.Line, e.UseReceivablesPoster), Registration, countrypack.TaxReturnDef.

  • Produces: money.Amount.Cents() Amount, money.Amount.MulDivCents(num, den Amount) (Amount, error); Computation{Fields []FieldFigure; Lines []SourceLine; Unmapped []string; TotalTax, TaxPayable money.Amount; Reconciliation Reconciliation}, FieldFigure{Key string; Rows []RateRow; Taxable, Tax money.Amount}, RateRow{Rate *money.Rate; Taxable, Tax money.Amount; Documents int}, SourceLine{FieldKey string; Rate *money.Rate; SourceKind string; DocumentID, AdjustmentID *uuid.UUID; Reference string; DocumentDate *time.Time; Taxable, Tax money.Amount}, Reconciliation{AccountID uuid.UUID; Movement, Expected, Difference, Deferred money.Amount}, Adjustment{ID; FieldKey; Taxable, Tax money.Amount; Reason string}; Compute(ctx, tx, def, reg Registration, p Period, adjustments []Adjustment) (Computation, error).

  • Step 1: cents.go test-first. Cents() rounds half away from zero to two places (1.005 → 1.01, -1.005 → -1.01, 2.3350 → 2.34); MulDivCents(num, den) = a × num / den rounded once to two places (8.00 × 54/108 = 4.00; 8.00 × 1/3 = 2.67; den zero → ErrDivideByZero).

// Cents rounds a to the sen, half away from zero.
func (a Amount) Cents() Amount { return Amount{d: a.d.Round(2)} }
// MulDivCents is a × num / den rounded once, half away from zero, to the
// sen: the share of a that num is of den (SST-02 spec D5).
func (a Amount) MulDivCents(num, den Amount) (Amount, error) {
if den.d.IsZero() {
return Amount{}, ErrDivideByZero
}
return Amount{d: a.d.Mul(num.d).DivRound(den.d, 2)}, nil
}

(decimal.Round is half away from zero — confirm against the kernel’s existing rounding test; Parse relies on the same.)

  • Step 2: Failing attribution tests attribute_test.go (package taxreturn, pure, no DB). Build facts directly:
func inv(date string, gross string, rate, taxable, tax string) invoiceFacts {
return invoiceFacts{Date: d(date), Gross: money.MustParse(gross),
Lines: map[string]bucket{rate: {money.MustParse(taxable), money.MustParse(tax)}}}
}
func pay(f *invoiceFacts, on, amt string) { f.Paid = append(f.Paid, dated{d(on), money.MustParse(amt)}) }
func TestServiceTaxFollowsPayment(t *testing.T) {
jul, sep := period("2026-07-01", "2026-08-31"), period("2026-09-01", "2026-10-31")
f := inv("2026-08-10", "108.00", "8", "100.00", "8.00")
// PRESENCE of the rule's input: unpaid, nothing is due in the invoice's own period.
require.Empty(t, mustFigure(t, f, jul))
pay(&f, "2026-09-05", "54.00")
require.Equal(t, bucket{m("50.00"), m("4.00")}, mustFigure(t, f, sep)["8"])
pay(&f, "2026-11-02", "54.00")
require.Equal(t, bucket{m("50.00"), m("4.00")}, mustFigure(t, f, period("2026-11-01", "2026-12-31"))["8"])
}

and one test each for: credit note before any payment (54/4 credit, paid 54 → 4.00 due, then nothing more); credit note after full payment (−4.00 in the credit note’s period); the 12-month rule (unpaid invoice of 2025-09-10 → full tax in the period containing 2026-09-10; a payment after that adds nothing); a reversal dated later (paid 108 in Sep → 8.00; reversal −108 in Nov → −8.00 in Nov–Dec); a foreign invoice (gross 100 USD, lines in base 420.00/33.60, paid 50 USD → 210.00/16.80); rounding (gross 100, tax 8.00, paid 33.33 → 2.67; the remainder 66.67 → 5.33; sum 8.00); telescoping (random sequence of payments and one credit note over six periods: Σ period figures = final net).

  • Step 3: Implement attribute.go.
package taxreturn
// bucket is a taxable value and its tax, in base currency.
type bucket struct{ Taxable, Tax money.Amount }
func (b bucket) sub(o bucket) bucket { return bucket{b.Taxable.Sub(o.Taxable), b.Tax.Sub(o.Tax)} }
func (b bucket) zero() bool { return b.Taxable.IsZero() && b.Tax.IsZero() }
type dated struct {
On time.Time
V money.Amount // document currency; reversals negative
}
type creditFacts struct {
Date time.Time
Gross money.Amount // document currency
Lines map[string]bucket // payment-basis lines, by rate
}
// invoiceFacts is everything D5 needs about one issued invoice with
// payment-basis lines. Rates are money.Rate.String() keys.
type invoiceFacts struct {
ID uuid.UUID
Reference string
Date time.Time
Gross money.Amount
Lines map[string]bucket
Credits []creditFacts
Paid []dated
}
// dueTo is D5's due(I, d): the tax attributable to f up to day d, per rate,
// each in sen.
func dueTo(f invoiceFacts, d time.Time, limitMonths int) (map[string]bucket, error) {
out := map[string]bucket{}
if d.Before(f.Date) {
return out, nil
}
net, grossNet := map[string]bucket{}, f.Gross
for r, b := range f.Lines {
net[r] = b
}
for _, c := range f.Credits {
if c.Date.After(d) {
continue
}
grossNet = grossNet.Sub(c.Gross)
for r, b := range c.Lines {
net[r] = net[r].sub(b)
}
}
paid := money.Zero()
for _, p := range f.Paid {
if !p.On.After(d) {
paid = paid.Add(p.V)
}
}
full := !d.Before(f.Date.AddDate(0, limitMonths, 0)) || !grossNet.IsPositive() || paid.Cmp(grossNet) >= 0
for r, b := range net {
if full {
out[r] = bucket{b.Taxable.Cents(), b.Tax.Cents()}
continue
}
if !paid.IsPositive() {
continue
}
t, err := b.Taxable.MulDivCents(paid, grossNet)
if err != nil {
return nil, err
}
x, err := b.Tax.MulDivCents(paid, grossNet)
if err != nil {
return nil, err
}
out[r] = bucket{t, x}
}
return out, nil
}
// figure is f's share of period p: due at the end less due the day before
// it starts. Zero rows are dropped.
func figure(f invoiceFacts, p Period, limitMonths int) (map[string]bucket, error) {
end, err := dueTo(f, p.End, limitMonths)
if err != nil {
return nil, err
}
before, err := dueTo(f, p.Start.AddDate(0, 0, -1), limitMonths)
if err != nil {
return nil, err
}
out := map[string]bucket{}
for r, b := range end {
out[r] = b
}
for r, b := range before {
out[r] = out[r].sub(b)
}
for r, b := range out {
if b.zero() {
delete(out, r)
}
}
return out, nil
}
  • Step 4: Run attribution tests — expect PASS.

  • Step 5: Failing compute tests compute_test.go (DB; e := taxreturntest.New(t), e.UseReceivablesPoster(t), e.Register(t, "2026-01-01", "2026-02-28"), today 2026-09-21, period 2026-09-01…2026-10-31):

    • TestSalesTaxIsReportedByRateOnTheDocumentDate — invoice A: ST10 1000.00 + ST5 200.00; credit note on A: ST10 100.00. Period figures: sales_tax rows 10 → 900.00 / 90.00, 5 → 200.00 / 10.00; SourceLines: A (two rates) and the credit note (negative). PRESENCE-before-ABSENCE: an invoice issued at e.At(t, "2026-11-02") appears in the Nov–Dec computation and not in Sep–Oct.
    • TestServiceTaxIsReportedAsItIsPaid — invoice SVT8 100.00 (gross 108), receipt 54.00 allocated → service_tax 8 → 50.00 / 4.00; Reconciliation.Deferred = 4.00.
    • TestExemptAndOutOfScopeAreInformationOnly — lines EXEMPT 300 and OS 50: fields report them; TaxPayable excludes them (presence: an ST10 line in the same invoice counts).
    • TestOnlySalesCodesReachTheReturn — AR refuses a purchase code (and, once WHT has merged, a withholding code) on a sales line (presence: a sales code is accepted), so a withholding line cannot reach a sales snapshot; the engine’s own by-kind routing is proven by the next test — no field is keyed by account anywhere in compute.go.
    • TestAnUnmappedKindIsReported — a sales code with pack_kind = 'tourism_tax' on an issued line → Unmapped == ["tourism_tax"]; presence: without it, Unmapped empty.
    • TestAdjustmentsCountWithTheirSign — imported_services 1000/80, deduction 0/10 → TaxPayable = 80 − 10 + document tax.
    • TestReconciliationIsZeroForDocumentBasisOnly — ST10 and ST5 invoices and a credit note only: Difference zero; PRESENCE of the mechanism: a manual journal Dr 6190 / Cr 2140 12.00 in the period (through ledger.Post in the world) makes Difference 12.00.
  • Step 6: Implement compute.go. The shape:

func Compute(ctx context.Context, tx pgx.Tx, def countrypack.TaxReturnDef, reg Registration, p Period, adj []Adjustment) (Computation, error)
  1. Document basis — one query, grouped per document, pack kind and rate (all kinds; Go routes them):
SELECT d.id, d.kind, d.reference, d.invoice_date, l.tax_pack_kind, l.tax_rate,
sum(l.net_base), sum(l.tax_base)
FROM sales_invoice d
JOIN sales_invoice_line l ON l.org_id = $1 AND l.invoice_id = d.id AND l.withdrawn_at IS NULL
WHERE d.org_id = $1 AND d.issued_at IS NOT NULL AND d.invoice_date BETWEEN $2 AND $3
GROUP BY d.id, d.kind, d.reference, d.invoice_date, l.tax_pack_kind, l.tax_rate
ORDER BY d.invoice_date, d.reference, l.tax_rate

For each row: sign = −1 for customer_credit_note. field, ok := def.FieldForKind(kind); !ok → add to Unmapped (sorted, unique). Document-basis field → a SourceLine (Cents() each amount, then sign). Payment-basis kinds are not a source line here, but their signed tax_base joins expected (the ledger posts them at issue, D13). Every document-basis field with Sign ≠ 0 also adds its signed tax to expected.

  1. Payment basis — for each payment-basis field (MY: one), load facts with four queries, $2 = p.End, $3 = the field’s pack kind:
-- invoices with lines of that kind, issued by the period end
SELECT d.id, d.reference, d.invoice_date, d.gross_amount FROM sales_invoice d
WHERE d.org_id = $1 AND d.kind = 'sales_invoice' AND d.issued_at IS NOT 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);
-- their lines of that kind, and their issued credit notes' lines of that kind, by rate
SELECT coalesce(d.original_invoice_id, d.id), 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.invoice_date <= $2
GROUP BY 1, 2, 3, 4, 5, 6;
-- payments (spec D5's dating)
SELECT a.invoice_id,
CASE WHEN a.receipt_id IS NOT NULL AND a.reverses_id IS NULL THEN greatest(r.receipt_date, i.invoice_date)
ELSE a.effective_on END,
a.amount
FROM ar_allocation a
JOIN sales_invoice i ON i.org_id = $1 AND i.id = a.invoice_id
LEFT JOIN customer_receipt r ON r.org_id = $1 AND r.id = a.receipt_id
LEFT JOIN sales_invoice cn ON cn.org_id = $1 AND cn.id = a.credit_note_id
WHERE a.org_id = $1 AND (a.receipt_id IS NOT NULL OR cn.original_invoice_id IS DISTINCT FROM a.invoice_id);

Assemble invoiceFacts per invoice id from query 1 (the second query’s credit-note rows attach to their original, a row with l.tax_rate IS NULL still carries the credit note’s gross; an invoice row adds its lines), then figure(f, p, field.PaymentLimitMonths) → one SourceLine per invoice and rate. Deferred = Σ over the same invoices of (net(I, p.End) − dueTo(I, p.End)) tax — add a small netTo(f, d) helper beside dueTo rather than duplicating its loop. ponytail: every issued payment-basis invoice dated ≤ period end is loaded (spec D5); an “attributed in full” marker is the upgrade.

  1. Adjustments → SourceLine{SourceKind: "adjustment", AdjustmentID, Reference: "ADJ " + reason[:min(40)]}, amounts as entered; each adds Sign × tax to expected.

  2. Fields — group Lines by field key and (for ByRate) rate into FieldFigures in def.Fields order (a field with no lines still appears, zero). TotalTax = Σ tax of fields with Sign ≠ 0 (unsigned: the gross tax figure the form’s total asks for, adjustments included with their sign); TaxPayable = Σ Sign × Tax.

  3. Reconciliation — Movement:

SELECT coalesce(sum(jl.base_credit - jl.base_debit), 0)
FROM journal_line jl
JOIN journal_entry je ON je.org_id = $1 AND je.id = jl.entry_id
WHERE jl.org_id = $1 AND jl.account_id = $2 AND je.entry_date BETWEEN $3 AND $4
AND je.source_kind IS DISTINCT FROM 'tax_return_payment'

Difference = Movement − Expected. Reversal entries of payments carry the same source_kind (the engine’s mirror) — confirm in ledger/reverse.go; if a reversal’s source_kind differs, exclude it too.

  • Step 7: Run — expect PASS. go test ./internal/kernel/money/... ./internal/platform/taxreturn/... -count=1.

  • Step 8: Sabotage. (a) In the payments query drop OR cn.original_invoice_id IS DISTINCT FROM a.invoice_id → a.receipt_id IS NOT NULL OR true: a credit note’s own auto-allocation counts as payment; TestServiceTaxIsReportedAsItIsPaid extended with a credit note goes red (over-attribution). (b) Replace greatest(r.receipt_date, i.invoice_date) with a.effective_on: add TestAReceiptIsDatedByItsReceiptDate (receipt recorded 2026-11-02 with receipt_date 2026-10-31 → counted in Sep–Oct) and watch it go red. (c) Flip the credit-note sign: TestSalesTaxIsReportedByRateOnTheDocumentDate red. Restore; record.

  • Step 9: Commit. SST-02 Task 3: computation — document basis, payment basis, reconciliation


Task 4: Return lifecycle — draft, amendment, adjustments, finalise, discard, stale, periods

Section titled “Task 4: Return lifecycle — draft, amendment, adjustments, finalise, discard, stale, periods”

Files:

  • Create: internal/platform/taxreturn/return.go, internal/platform/taxreturn/return_test.go

Interfaces:

  • Consumes: Compute, Registration, Periods, PeriodStarting, company.Get.

  • Produces: Return{ID, RegistrationID uuid.UUID; Period Period; Version int; Status string; AmendsID *uuid.UUID; AmendmentReason *string; RegistrationNo *string; TotalTax, TaxPayable *money.Amount; PreparedBy uuid.UUID; FinalisedBy *uuid.UUID; FinalisedAt *time.Time; Revision int; Adjustments []Adjustment}, View{Return; Computation; Stale, Finalisable bool; LiveTaxPayable money.Amount; Paid, Outstanding money.Amount; Overdue bool; Versions []Return} (Finalisable = draft, today > period end, no unmapped kinds), PeriodSummary{Period; Latest *Return; TaxPayable, Paid, Outstanding money.Amount; Overdue, Stale bool}; funcs CreateReturn(ctx, tx, c, actor, periodStart time.Time, reason string) (Return, error), GetReturn(ctx, tx, id) (Return, error), lockReturn, ViewReturn(ctx, tx, c, id) (View, error), SourceLines(ctx, tx, c, id, fieldKey string) ([]SourceLine, error), SetAdjustments(ctx, tx, c, actor, id uuid.UUID, revision int, in []AdjustmentInput) (Return, error), Finalise(ctx, tx, c, actor, id uuid.UUID, revision int) (Return, error), Discard(ctx, tx, c, actor, id uuid.UUID, reason string) (Return, error), ListPeriods(ctx, tx, c) ([]PeriodSummary, error), paidFor(ctx, tx, reg, periodStart) (money.Amount, error) (Σ payments in payout — returns zero until Task 5 creates the table; Task 5 fills it in); errors ErrNotAPeriod (422 not_a_period), ErrDraftExists, ErrAmendmentReason (422), ErrPeriodNotEnded, ErrUnmappedKind, ErrRegistrationNoMissing, ErrNotDraft, ErrNotFound.

  • Step 1: Failing tests return_test.go:

    • TestADraftIsComputedLive — create for Sep–Oct; issue an ST10 invoice afterwards; ViewReturn includes it (live).
    • TestFinaliseWaitsForThePeriodToEnd — Jul–Aug draft at today 2026-09-21: finalise succeeds (presence); Sep–Oct draft at today → ErrPeriodNotEnded; after e.At(t, "2026-11-01") it succeeds.
    • TestFinaliseFreezesTheFigures — finalise with an invoice and an adjustment; tax_return_line rows = the computation’s SourceLines; registration_no, total_tax, tax_payable snapshotted; issue another invoice dated in the period — impossible after the period (no back-dating), so instead record a receipt with receipt_date in the period for a service-tax invoice: ViewReturn still shows the frozen figures and Stale == true, LiveTaxPayable = frozen + the new share. PRESENCE: before the receipt, Stale == false.
    • TestOneDraftPerPeriodAndAmendmentsNeedAReason — second create while a draft exists → ErrDraftExists; after finalise, create without reason → ErrAmendmentReason; with reason → version 2, AmendsID = v1, adjustments copied (new ids, same values).
    • TestAnUnmappedKindBlocksFinalising — (the Task 3 fixture) → ErrUnmappedKind; presence: without it finalise succeeds.
    • TestTheRegistrationNumberIsRequiredToFinalise — blank SST number → ErrRegistrationNoMissing.
    • TestAdjustmentsOnlyOnManualFieldsAndOnlyInADraft — sales_tax as a field → 422 adjustment_field; tax 0 → 422 adjustment_amount; reason 咖啡 → 422 record_language; presence: imported_services accepted; after finalise → ErrNotDraft.
    • TestDiscardedDraftsAreKept — discard v1 with a reason; the row remains, status = discarded; a new draft is version 2 with amends_id NULL (nothing finalised yet, so no reason needed); finalise it; the next draft is version 3, amends_id = v2, reason required (Ruling R13).
    • TestPeriodsListShowsStatusAndOverdue — registration Jan–Feb cycle, today 2026-09-21: five periods, newest first; Jul–Aug (due 2026-09-30) not overdue; May–Jun (due 2026-07-31) with no return overdue; finalise it → still overdue while outstanding > 0 (a positive payable), Outstanding = its payable (no payments yet).
  • Step 2: Implement return.go.

    • CreateReturn: Definition, GetRegistration, PeriodStarting (else ErrNotAPeriod); read the period’s rows ORDER BY version DESC; if a draft exists → ErrDraftExists; version and amends_id per R13 — with a finalised version the reason is required (recordtext.Check, ≤ 500), without one it must be empty; insert; for an amendment, copy the amended version’s non-withdrawn adjustments; audit.Write last. Map tax_return_one_draft to ErrDraftExists too (the race).
    • SetAdjustments: lockReturn, Status == draft else ErrNotDraft, revision check, validate each (def.Field(key) with Basis == manual; amounts ≥ 0 / > 0 and in sen; reason record text), UPDATE … SET withdrawn_at = now for live rows, insert new, bump revision, audit.
    • Finalise:
func Finalise(ctx context.Context, tx pgx.Tx, c clock.Clock, actor, id uuid.UUID, revision int) (Return, error) {
org, err := orgOf(ctx)
if err != nil {
return Return{}, err
}
r, err := lockReturn(ctx, tx, id)
if err != nil {
return Return{}, err
}
switch {
case r.Status != StatusDraft:
return Return{}, ErrNotDraft
case r.Revision != revision:
return Return{}, ErrStaleRevision
case !clock.TodayUTC(c).After(r.Period.End):
return Return{}, ErrPeriodNotEnded
}
def, err := Definition(ctx, tx)
if err != nil {
return Return{}, err
}
reg, err := GetRegistration(ctx, tx)
if err != nil {
return Return{}, err
}
if strings.TrimSpace(reg.RegistrationNo) == "" {
return Return{}, ErrRegistrationNoMissing
}
comp, err := Compute(ctx, tx, def, reg, r.Period, r.Adjustments)
if err != nil {
return Return{}, err
}
if len(comp.Unmapped) > 0 {
return Return{}, &UnmappedKindError{Kinds: comp.Unmapped} // errors.Is(err, ErrUnmappedKind)
}
for _, l := range comp.Lines {
if _, err := tx.Exec(ctx, `
INSERT INTO tax_return_line (org_id, return_id, field_key, rate, source_kind, document_id, adjustment_id,
reference, document_date, taxable_amount, tax_amount)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
org, r.ID, l.FieldKey, l.Rate, l.SourceKind, l.DocumentID, l.AdjustmentID, l.Reference, l.DocumentDate,
l.Taxable, l.Tax); err != nil {
return Return{}, fmt.Errorf("taxreturn: finalise: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE tax_return SET status = 'finalised', registration_no = $3, total_tax = $4, tax_payable = $5,
finalised_by = $6, finalised_at = $7, updated_at = $7, revision = revision + 1
WHERE org_id = $1 AND id = $2`,
org, r.ID, reg.RegistrationNo, comp.TotalTax, comp.TaxPayable, actor, c.Now()); err != nil {
return Return{}, fmt.Errorf("taxreturn: finalise: %w", err)
}
after, err := GetReturn(ctx, tx, r.ID)
if err != nil {
return Return{}, err
}
return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityReturn, EntityID: r.ID, Action: "finalise",
Before: map[string]string{"status": r.Status}, After: after})
}
  • ViewReturn: draft → Compute live; finalised → figures from tax_return_line (grouped exactly as Compute groups, one shared group(def, lines) helper), plus a live Compute for Stale (live.TaxPayable ≠ frozen or any field/rate differs) and LiveTaxPayable. Paid/Outstanding per period via paidFor (latest finalised version’s payable − paid); Overdue per spec D11. Versions: every row of the period, newest first.

  • SourceLines: frozen lines for a finalised return, Compute(...).Lines for a draft, filtered by field key.

  • ListPeriods: reg.Periods(def, today) reversed; one query for all rows of the registration; one paidFor per period that has a finalised return (≤ a few dozen periods; no N+1 worth fixing). Stale is not computed in the list (it would compute every filed period); the drawer computes it.

  • Step 3: Run — expect PASS.

  • Step 4: Sabotage. (a) Remove the !clock.TodayUTC(c).After(r.Period.End) case: TestFinaliseWaitsForThePeriodToEnd red. (b) Make ViewReturn return Stale: false always: TestFinaliseFreezesTheFigures red at the stale assertion. (c) Skip copying adjustments on amendment: the amendment test red. Restore; record.

  • Step 5: Commit. SST-02 Task 4: return lifecycle — draft, amend, finalise, stale, periods


Task 5: Paying the tax — kind, graph, payout rule, payment service, drift test

Section titled “Task 5: Paying the tax — kind, graph, payout rule, payment service, drift test”

Files:

  • Create: db/migrations/00401_tax_return_payment.sql
  • Modify: internal/kernel/db/retention_test.go
  • Create: internal/platform/taxreturn/graphs.go, graphs_test.go, payment.go, payment_test.go, posting.go, posting_test.go, reconcile_test.go
  • Modify: internal/platform/taxreturn/return.go (paidFor), taxreturntest/env.go (UseTaxReturnPoster, Payment, Submit, Approve)

Interfaces:

  • Consumes: workflow.RegisterSeededGraph, workflow.Start/Transition/SetAmount, numbering.Allocate/Format, fiscal.YearContaining, chart.GetAccount/ListJournals, accounting.PostingRule.

  • Produces: KindPayment = "tax_return_payment", PaymentGraph(ownerSeat) workflow.Graph, Payment{ID; Reference; ReturnID; BankAccountID, TaxAccountID uuid.UUID; PaymentDate time.Time; BankReference string; Amount money.Amount; InstanceID, PreparedBy uuid.UUID; State, Semantic string; Revision int}, PaymentInput{ReturnID, BankAccountID uuid.UUID; PaymentDate time.Time; BankReference string; Amount money.Amount}, CreatePayment, UpdatePayment(…, expectedRevision, in), GetPayment, ListPayments(ctx, tx, f PaymentFilter{ReturnID *uuid.UUID; State string}), TransitionPayment(ctx, tx, c, TransitionInput) (Payment, workflow.Decision, error), Available, Rules() []accounting.PostingRule, PaymentPayoutRule; errors ErrReturnNotFinalised, ErrOverOutstanding (422 amount_over_outstanding), ErrBankAccount (422 bank_account), ErrInstanceMismatch.

  • Step 1: Migration 00401_tax_return_payment.sql — supplier_payment’s shape (00304) with: return_id (FK tax_return), tax_account_id (FK account), no supplier, no currency/fx (base only), amount NUMERIC(19,4) > 0 with a cents CHECK, payment_date DATE, bank_reference (≤ 64, record language), reference unique, instance_id unique (FK workflow_instance), prepared_by, revision, timestamps; RLS ×4; REVOKE DELETE; tax_return_payment_draft_guard on UPDATE OF return_id, bank_account_id, tax_account_id, payment_date, bank_reference, amount via public.payables_semantic (00304’s function copied with the table/constraint renamed, constraint tax_return_payment_is_draft). Retention: amendable. A schema test (presence: a draft payment’s amount updates; after the instance leaves draft, the same UPDATE → tax_return_payment_is_draft).

  • Step 2: Graph. graphs.go — AP’s PaymentGraph (internal/platform/payables/graphs.go) copied with Kind: KindPayment and the helpers ls/st/ed/why/seat copied (unexported, no control kind on any state); init() registers it. Labels: Paid “Paid” / “已付款” / “Telah dibayar”; the approval edge “Approve and record payment” / “批准并记录付款” / “Luluskan dan rekod bayaran”. graphs_test.go: the graph passes the publish-time validator (workflow.Validate-equivalent the AP graphs test uses) and ships three locales.

  • Step 3: Failing payment tests payment_test.go (world: registration Jan–Feb cycle; e.UseTaxReturnPoster(t); a finalised Jul–Aug return with an imported_services adjustment 1000/80, so its payable is 80.00):

    • TestAPaymentPostsDrTaxAccountCrBank — create 80.00 from 1520 → submit (Staff) → approve (Owner): the entry has Dr 2140 80.00, Cr 1520 80.00, source_kind = tax_return_payment, base currency; ViewReturn(...).Outstanding = 0.
    • TestAPaymentCannotExceedOutstanding — presence: 50.00 accepted; a second 40.00 refused at create (50 pending + 40 > 80) → 422 amount_over_outstanding; approve the first; a 30.01 refused, 30.00 accepted.
    • TestTwoPendingPaymentsCannotBothPayTheSameTax — two 80.00 drafts (drafts are not counted); submit the first (presence); submitting the second → ErrOverOutstanding (80 pending + 80 > 80).
    • TestApprovalRechecksAfterAnAmendment — an 80.00 payment pending; amend the return (v2, adjustment tax 50.00, finalised); approving the payment → ErrOverOutstanding (outstanding is now 50.00, read under the lock); presence: a 50.00 payment approves.
    • TestOnlyAFinalisedReturnCanBePaid — a draft return → ErrReturnNotFinalised; presence: its finalised version accepted.
    • TestThePreparerCannotApprove — Staff prepares and tries approve_payment → the engine’s self-approval refusal; presence: Owner can.
    • TestVoidReversesAndRestoresOutstanding — void (Owner, reason) → reversal entry; Outstanding back to 80.00.
    • TestTheBankAccountMustBeABankControlInBase — 2140 → ErrBankAccount; presence 1520.
  • Step 4: Implement payment.go, posting.go. CreatePayment mirrors payables.CreatePayment (numbering TXP, workflow.Start{Kind: KindPayment, SourceID: id, Reporter: actor, Amount: in.Amount}), with checkPayment:

func checkPayment(ctx context.Context, tx pgx.Tx, c clock.Clock, in *PaymentInput, exclude *uuid.UUID) (Return, Registration, error) {
in.BankReference = strings.TrimSpace(in.BankReference)
switch {
case !in.Amount.IsPositive() || in.Amount.Cmp(in.Amount.Cents()) != 0:
return Return{}, Registration{}, fieldErr("amount", "positive", "a positive amount in sen")
case in.PaymentDate.After(clock.TodayUTC(c)):
return Return{}, Registration{}, fieldErr("payment_date", "payment_date_future", "the tax cannot have been paid after today")
case len(in.BankReference) > 64 || recordtext.Check("bank_reference", in.BankReference) != nil:
return Return{}, Registration{}, fieldErr("bank_reference", "record_language", "a reference of up to 64 characters in English or Bahasa Malaysia")
}
r, err := GetReturn(ctx, tx, in.ReturnID)
if err != nil {
return Return{}, Registration{}, err
}
if r.Status != StatusFinalised {
return Return{}, Registration{}, ErrReturnNotFinalised
}
reg, err := GetRegistration(ctx, tx)
if err != nil {
return Return{}, Registration{}, err
}
profile, err := company.Get(ctx, tx)
if err != nil {
return Return{}, Registration{}, err
}
a, err := chart.GetAccount(ctx, tx, in.BankAccountID)
if err != nil && !errors.Is(err, chart.ErrNotFound) {
return Return{}, Registration{}, err
}
if err != nil || !a.IsActive || !a.IsPostable || a.ControlKind == nil || *a.ControlKind != "bank" ||
(a.Currency != nil && *a.Currency != profile.BaseCurrency) {
return Return{}, Registration{}, ErrBankAccount
}
out, err := outstanding(ctx, tx, reg, r.Period.Start, false)
if err != nil {
return Return{}, Registration{}, err
}
pending, err := pendingFor(ctx, tx, reg, r.Period.Start, exclude) // Σ amounts in semantic 'pending'
if err != nil {
return Return{}, Registration{}, err
}
if in.Amount.Cmp(out.Sub(pending)) > 0 {
return Return{}, Registration{}, &OverOutstandingError{Outstanding: out.Sub(pending)}
}
return r, reg, nil
}

outstanding(ctx, tx, reg, periodStart, lock bool): SELECT id, tax_payable FROM tax_return WHERE org_id = $1 AND registration_id = $2 AND period_start = $3 AND status = 'finalised' ORDER BY version DESC LIMIT 1 (+ FOR UPDATE when lock), minus paidFor. paidFor (in return.go, replacing Task 4’s zero):

SELECT coalesce(sum(p.amount), 0)
FROM tax_return_payment p
JOIN tax_return r ON r.org_id = $1 AND r.id = p.return_id
WHERE p.org_id = $1 AND r.registration_id = $2 AND r.period_start = $3
AND public.payables_semantic(p.org_id, p.instance_id) = 'payout'

TransitionPayment: lockPayment → destination (AP’s transition.go helper copied) → if from is draft and to is not cancelled/rejected: checkPayment(…, &p.ID); if to.Semantic == posting.Payout: outstanding(…, lock=true) and refuse p.Amount > out → workflow.Transition → audit last. The payout rule:

// PaymentPayoutRule (spec D10): Dr the payment's tax account, Cr the bank,
// in base currency, on the bank account's journal.
type PaymentPayoutRule struct{}
var _ accounting.PostingRule = PaymentPayoutRule{}
func (PaymentPayoutRule) Kind() string { return KindPayment }
func (PaymentPayoutRule) Semantic() posting.Semantic { return posting.Payout }
func (PaymentPayoutRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) {
p, err := GetPayment(ctx, tx, src.Event.SourceID)
if err != nil {
return accounting.Draft{}, fmt.Errorf("taxreturn: payout: %w", err)
}
if p.InstanceID != src.Event.InstanceID {
return accounting.Draft{}, ErrInstanceMismatch
}
r, err := GetReturn(ctx, tx, p.ReturnID)
if err != nil {
return accounting.Draft{}, fmt.Errorf("taxreturn: payout: %w", err)
}
label := fmt.Sprintf("%s %s–%s v%d", p.Reference, r.Period.Start.Format("2006-01-02"), r.Period.End.Format("2006-01-02"), r.Version)
amt := p.Amount
jid, jtype, err := journalFor(ctx, tx, p.BankAccountID)
if err != nil {
return accounting.Draft{}, err
}
return accounting.Draft{JournalID: jid, JournalType: jtype, Currency: src.BaseCurrency,
Description: "Tax return payment " + label,
Lines: []ledger.Line{
{AccountID: p.TaxAccountID, Debit: amt, Base: &amt, Memo: label},
{AccountID: p.BankAccountID, Credit: amt, Base: &amt, Memo: p.Reference + " " + p.BankReference},
}}, nil
}
func Rules() []accounting.PostingRule { return []accounting.PostingRule{PaymentPayoutRule{}} }

journalFor is payables.journalFor copied (AP P25’s own precedent: copied, not shared). e.UseTaxReturnPoster(t) registers a Poster with receivables.Rules() + Rules() (mirror receivablestest’s UseReceivablesPoster).

  • Step 5: Drift test reconcile_test.go — TestTheReturnReconcilesWithTheTaxAccount: a mixed sequence in Jul–Aug (ST10 invoice, ST5 invoice, a credit note, an imported_services adjustment 1000/80 with its journal Dr 6190 80 / Cr 2140 80 posted through ledger.Post as the preparer would), finalise, pay: Reconciliation.Difference is zero and the tax account’s base balance after payment = Σ over the period of expected − payments (zero here once paid in full). Watch it fail: post the adjustment’s journal to 2150 instead of 2140 → Difference = −80.00, red; restore.

  • Step 6: Run — expect PASS. go test ./internal/platform/taxreturn/... ./internal/kernel/db/... -count=1.

  • Step 7: Sabotage. (a) Remove the approval-time outstanding(…, lock=true) recheck: TestApprovalRechecksAfterAnAmendment red. (b) Credit p.TaxAccountID instead of debiting it: the posting test red. Restore; record.

  • Step 8: Commit. SST-02 Task 5: tax payment kind, payout rule, reconciliation drift test


Task 6: The taxreturns module — permissions, grants, OpenAPI, routes, wiring

Section titled “Task 6: The taxreturns module — permissions, grants, OpenAPI, routes, wiring”

Files:

  • Modify: constants.yaml (+ generated vocab), api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts)
  • Create: db/migrations/00402_tax_return_grants.sql (+ a migration test mirroring payables/migration00305_test.go)
  • Create: internal/modules/taxreturns/{permissions.go,boot.go,routes.go,http.go,errors.go,errors_test.go,registration_handlers.go,return_handlers.go,payment_handlers.go} and their _test.go files (mirror internal/modules/payables)
  • Modify: internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go

Interfaces:

  • Consumes: every taxreturn function above.

  • Produces: routes of spec §API; permissions taxreturns.return.read, taxreturns.return.manage, taxreturns.return.finalise, taxreturns.payment.create, taxreturns.payment.transition.

  • Step 1: Permissions. constants.yaml gains the five keys after payables.payment.transition; make generate. permissions.go registers them with sentences (“Read tax registrations, taxable periods, returns and tax payments”; “Set up the tax registration (including the SST number on the company profile), prepare return drafts, adjustments and amendments, discard drafts”; “Finalise a tax return: freeze its figures as filed”; “Prepare tax payments and edit their drafts”; “Use the tax payment transition endpoint; authority to approve still comes from the workflow’s grants”) and provisioning.RegisterRolePermissions("Finance", …all five…, "accounting.ledger.read"). boot.go registers taxreturn.Rules() in one loop (AP’s comment). 00402 = 00305 with the five keys for both Owner and Finance (no Owner-only key), the perm_version bump, and a Down that deletes taxreturns.%.

  • Step 2: OpenAPI first. Add the paths and schemas of spec §API. Money fields $ref: Money, rates Rate, dates Date. Tags TaxReturns. The PUT /tax-returns/registration description states the side effect: “Also sets the SST number on the company profile, which new sales invoices snapshot.” make generate; the generated interface drives the handlers.

  • Step 3: Failing handler tests (mirror internal/modules/payables/*_handlers_test.go): each route 403 without its permission (presence: 200 with it); GET /tax-returns/registration 404 no_registration then 200 after PUT; POST /tax-returns 422 not_a_period on 2026-03-02; finalise 409 period_not_ended; the error mapping table in errors_test.go covers every sentinel of Tasks 2–5 (FieldError → 422 with field/rule; ErrDraftExists, ErrRegistrationFrozen, ErrPeriodNotEnded, ErrUnmappedKind, ErrRegistrationNoMissing, ErrNotDraft, ErrStaleRevision, ErrReturnNotFinalised → 409 with the code as the problem’s code; ErrNoTaxReturn, ErrNoRegistration, ErrNotFound → 404; OverOutstandingError, ErrBankAccount → 422).

  • Step 4: Implement the handlers. Every handler opens one db.InTx (reads too); posting handlers (payment transition) pass the error through ledger.MapError. The return DTO: sections from def.Fields in order with labels{en,ms,zh} = the field’s Names, form_ref, form_ref_verified = def.FormRefsVerified, sign; rows with documents counts; unmapped; reconciliation; adjustments; versions; payments (ListPayments{ReturnID} for every version of the period).

  • Step 5: Wiring. Compose the module exactly where and how payables is composed in internal/app/app.go and the four cmd/*/main.go (the worker and migrate binaries import it for its init() registrations — the wiring tests fail otherwise). routes_test.go’s expected route list gains the new routes.

  • Step 6: Run — expect PASS. go test ./internal/modules/taxreturns/... ./internal/app/... ./cmd/... -count=1; make generate && git status clean.

  • Step 7: Sabotage. Drop httpx.Protected from POST /tax-returns/{id}/finalise: TestTheOpenAPIDocumentSaysWhatTheRouterEnforces (or the module’s own 403 test) red. Restore; record.

  • Step 8: Commit. SST-02 Task 6: taxreturns module, permissions, OpenAPI, wiring


Task 7: The figures sheet — tax_return export

Section titled “Task 7: The figures sheet — tax_return export”

Files:

  • Create: db/migrations/00403_report_export_tax_return.sql
  • Modify: internal/platform/reporting/export/{export.go,params.go,project.go} (+ tests), api/openapi.yaml (+ generated)

Interfaces:

  • Consumes: taxreturn.ViewReturn, taxreturn.Definition, render.Writer.

  • Produces: export.ReportTaxReturn = "tax_return", Params.ReturnID *uuid.UUID (return_id).

  • Step 1: Migration. 00403 rebuilds report_export_report_known with every report on the branch at build time plus tax_return (read the latest report_export_report_known migration on the branch; program ruling: the controller re-unions at merge). Down restores that list.

  • Step 2: Failing tests. params_test.go: tax_return takes only return_id (required, 422 InvalidParamsError{Field: "return_id"} when absent; from refused as “not taken by tax_return”). export.Create with an unknown return id → InvalidParamsError{Field: "return_id"}; the worker treats taxreturn.ErrNotFound as invalid_parameters (permanent). A render test (CSV): a finalised return’s sheet has the meta rows (Organization, Registration no. = the SST number, Period, Due, Version, Status, Finalised by/at), one row per field and rate in pack order with the form reference suffixed “ (unverified)“, the tax payable row, the adjustments with reasons, the three reconciliation rows, and the closing note def.SheetNote (pack data, because it names the portal). A draft’s title is prefixed DRAFT — not filed. The export package’s own CHECK drift test accepts tax_return.

  • Step 3: Implement. export.go: the constant and title “Tax return”; params.go: allowed[ReportTaxReturn] = {"return_id": true}, present() and Validate cases (the as_of branch’s shape, keyed on return_id); project.go: projectTaxReturn — ViewReturn, meta with the return’s own fields appended (meta reads x.Report; add a branch that skips From/To for tax_return), columns Form reference (w 10), Item (w 14), Rate % (w 4, numeric), Taxable value (money), Tax (money); rows via render.Text/Money/Bold. PDF/XLSX show the English label with the Malay label on a second line (Names["en"] + "\n" + Names["ms"] — confirm the renderer wraps \n; if not, two columns). OpenAPI: ReportName enum gains tax_return, ExportParams.return_id. make generate.

  • Step 4: Run — expect PASS. go test ./internal/platform/reporting/... -count=1.

  • Step 5: Sabotage. Remove tax_return from 00403’s list: the CHECK drift test red. Restore; record.

  • Step 6: Commit. SST-02 Task 7: tax_return figures sheet export


Task 8: Web — API hooks, translations, nav, periods, registration, payments tab

Section titled “Task 8: Web — API hooks, translations, nav, periods, registration, payments tab”

Files:

  • Create: web/src/features/taxreturns/{api.ts,routes.tsx,TaxReturnsPage.tsx,RegistrationDialog.tsx,PaymentsTab.tsx}, web/src/features/taxreturns/__tests__/{TaxReturnsPage.test.tsx,RegistrationDialog.test.tsx}
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/taxreturns.json
  • Modify: web/src/app/shell/nav.ts, web/src/app/routes.ts, the i18n namespace registry (wherever payables is registered)

Interfaces:

  • Consumes: the generated client (schema.d.ts), DataTable, Tabs, Dialog, Field, Input, DatePicker, Select, Combobox, Tag, EmptyState, PageHeader, web/src/lib/money.ts.

  • Produces: hooks useRegistration, usePutRegistration, usePeriods, useTaxReturn(id), useCreateReturn, useSetAdjustments, useFinalise, useDiscard, useReturnLines(id, field), usePayments(filter), usePayment(id), useCreatePayment, useUpdatePayment, usePaymentTransition (mirror web/src/features/payables/api.ts: query keys, invalidation of periods and the return on every mutation).

  • Step 1: Translations (taxreturns.json; identical keys in all three; these are the strings to use):

key en ms zh
nav Tax returns Penyata cukai 税务申报
tabs.periods Periods Tempoh 申报期
tabs.payments Payments Bayaran 付款
registration.title SST registration Pendaftaran SST SST 登记
registration.setup Set up registration Sediakan pendaftaran 设置登记
registration.edit Edit registration Sunting pendaftaran 编辑登记
registration.registrationNo SST registration number Nombor pendaftaran SST SST 登记号
registration.effectiveFrom Effective from Berkuat kuasa mulai 生效日期
registration.firstPeriodEnd First taxable period ends Tempoh bercukai pertama berakhir 首个应税期结束日
registration.periodMonths Months per taxable period Bulan bagi setiap tempoh bercukai 每个应税期的月数
registration.taxAccount SST payable account Akaun SST belum bayar 应付 SST 账户
registration.frozen The period fields are fixed once a return is finalised. Medan tempoh tidak boleh diubah selepas penyata dimuktamadkan. 申报表定稿后,申报期字段不可更改。
registration.profileNote Saving also updates the SST number on the company profile, which new invoices use. Menyimpan juga mengemas kini nombor SST pada profil syarikat, yang digunakan oleh invois baharu. 保存时也会更新公司资料中的 SST 号码,新发票将使用该号码。
empty.noRegistration No registration yet. Set up the SST registration to see your taxable periods. Belum ada pendaftaran. Sediakan pendaftaran SST untuk melihat tempoh bercukai anda. 尚未登记。设置 SST 登记后即可查看应税期。
empty.noReturn Tax returns are not available for this organization’s country. Penyata cukai tidak tersedia untuk negara organisasi ini. 本组织所在国家暂不支持税务申报。
col.period Taxable period Tempoh bercukai 应税期
col.due Due Tarikh akhir 截止日期
col.status Status Status 状态
col.taxPayable Tax payable Cukai kena dibayar 应缴税款
col.paid Paid Dibayar 已付
col.outstanding Outstanding Tertunggak 未付
status.none Not started Belum dimulakan 未开始
status.draft Draft Draf 草稿
status.finalised Finalised Dimuktamadkan 已定稿
status.discarded Discarded Dibuang 已弃用
tag.overdue Overdue Lewat 逾期
tag.stale Changed since filed Berubah sejak difailkan 提交后已变动
tag.credit Credit — claim from the Customs department Kredit — tuntut daripada Jabatan Kastam 抵免——向海关申请
version Version {{n}} Versi {{n}} 第 {{n}} 版
action.prepare Prepare return Sediakan penyata 准备申报表
action.amend Amend Pinda 修正
action.finalise Finalise Muktamadkan 定稿
action.discard Discard draft Buang draf 弃用草稿
action.editAdjustments Edit adjustments Sunting pelarasan 编辑调整
action.recordPayment Record payment Rekod bayaran 记录付款
confirm.finalise Finalise the return for {{period}}? Its figures are frozen; corrections are made by amendment. Muktamadkan penyata bagi {{period}}? Angkanya akan dibekukan; pembetulan dibuat melalui pindaan. 确定将 {{period}} 的申报表定稿?数字将被冻结,更正需通过修正申报。
field.amendmentReason Reason for amendment Sebab pindaan 修正原因
field.discardReason Reason Sebab 原因
banner.stale The records now differ from this filed return: tax payable would be {{amount}}. File an amendment. Rekod kini berbeza daripada penyata yang difailkan ini: cukai kena dibayar sepatutnya {{amount}}. Failkan pindaan. 记录已与此已提交的申报表不一致:应缴税款应为 {{amount}}。请提交修正申报。
banner.unmapped Some sales lines use a tax kind this return does not report: {{kinds}}. Ask an administrator before finalising. Sesetengah baris jualan menggunakan jenis cukai yang tidak dilaporkan dalam penyata ini: {{kinds}}. Rujuk pentadbir sebelum memuktamadkan. 部分销售明细使用了本申报表未涵盖的税种:{{kinds}}。定稿前请咨询管理员。
banner.draft Draft — figures are recalculated from the records each time you open it. Draf — angka dikira semula daripada rekod setiap kali anda membukanya. 草稿——每次打开时都会根据记录重新计算。
formRef.unverified (form reference unverified) (rujukan borang belum disahkan) (表格编号未核实)
col.rate Rate Kadar 税率
col.taxable Taxable value Nilai bercukai 应税价值
col.tax Tax Cukai 税额
col.documents Documents Dokumen 单据
total.tax Total tax Jumlah cukai 税款合计
total.payable Tax payable Cukai kena dibayar 应缴税款
adjustment.field Item Butiran 项目
adjustment.reason Reason Sebab 原因
adjustment.add Add adjustment Tambah pelarasan 添加调整
recon.title Reconciliation with the SST payable account Penyesuaian dengan akaun SST belum bayar 与应付 SST 账户对账
recon.movement Movement on the account in the period Pergerakan akaun dalam tempoh ini 本期账户变动
recon.expected Expected from documents and adjustments Dijangka daripada dokumen dan pelarasan 根据单据和调整的预期金额
recon.difference Difference Perbezaan 差额
recon.deferred Service tax charged, not yet due Cukai perkhidmatan dikenakan, belum kena dibayar 已收取但尚未到期的服务税
payment.bankAccount Paid from Dibayar daripada 付款账户
payment.date Payment date Tarikh bayaran 付款日期
payment.reference Payment reference Rujukan bayaran 付款参考号
payment.amount Amount Jumlah 金额
payment.return Return Penyata 申报表
payment.state State Keadaan 状态

Add further keys the components need in the same three-language form; i18nparity.test.ts must pass.

  • Step 2: Failing tests (Vitest + MSW, mirror web/src/features/payables/__tests__): the page shows empty.noRegistration on 404 no_registration and empty.noReturn on 404 no_tax_return; with a registration it lists periods newest first with status and Overdue tags; Prepare return on a none row calls POST /tax-returns with that period_start; the registration dialog prefills and PUTs, shows registration.frozen and disables the three period fields when frozen; the payments tab lists payments with a state filter; no float (parseFloat, Number() on money (nofloat.test.ts).

  • Step 3: Implement. nav.ts: append { group: 'accounting', to: '/tax-returns', label: 'taxreturns:nav', permission: 'taxreturns.return.read', icon: <the receipt/percent icon the nav already imports, or the closest> } after the last accounting item (append-only rule). routes.tsx/routes.ts: /tax-returns with ?tab= and ?return=<id> search params (the drawer is Task 9; render nothing for return yet). TaxReturnsPage: PageHeader, registration card (number, effective, cycle, account; Set up/Edit gated on taxreturns.return.manage), Tabs (periods DataTable tableId="taxreturns.periods" with period as the always-shown identifier and tax payable as the amount; payments DataTable tableId="taxreturns.payments"). Row click sets ?return= (latest id) or, for none, shows Prepare return.

  • Step 4: Run — expect PASS. pnpm -C web test -- taxreturns && pnpm -C web lint && pnpm -C web typecheck.

  • Step 5: Commit. SST-02 Task 8: web — tax returns page, registration, periods, payments


Task 9: Web — the return drawer, adjustments, finalise, amend, export, payment

Section titled “Task 9: Web — the return drawer, adjustments, finalise, amend, export, payment”

Files:

  • Create: web/src/features/taxreturns/{ReturnDrawer.tsx,AdjustmentsDialog.tsx,ReturnLines.tsx,PaymentDialog.tsx,PaymentDrawer.tsx}, __tests__/{ReturnDrawer.test.tsx,PaymentDialog.test.tsx}
  • Modify: web/src/features/taxreturns/TaxReturnsPage.tsx, routes.tsx, the three taxreturns.json

Interfaces:

  • Consumes: Task 8’s hooks, ExportMenu (web/src/features/reports/ExportMenu.tsx, report tax_return, params { return_id }), ConfirmDialog, Drawer, the payment transition shape of web/src/features/payables/PaymentDrawer.tsx.

  • Step 1: Failing tests: a draft drawer shows banner.draft, sections in order with each rate row and formRef.unverified beside each reference when form_ref_verified is false, the adjustments list and Edit adjustments; the unmapped banner disables Finalise; Finalise opens the confirm with the period and posts {revision}; Finalise is disabled while today ≤ period_end (the DTO’s finalisable flag, computed server-side: draft, period ended, no unmapped kinds); a finalised drawer shows Amend (reason required) and, when stale, banner.stale with live_tax_payable; the rows’ document count opens ReturnLines (GET …/lines?field_key); ExportMenu requests tax_return with return_id; Record payment opens the dialog prefilled with outstanding, bank accounts from GET /receivables/deposit-accounts filtered to control kind bank (or the chart’s bank accounts endpoint AP’s PaymentDialog uses — use AP’s), and posts; the payment drawer shows the available transitions (AP’s PaymentDrawer pattern, the reason prompt on void/reject).

  • Step 2: Implement. Labels come from section.labels[i18n.language] ?? section.labels.en. Money formatted with web/src/lib/money.ts, never Number. AdjustmentsDialog: rows of (manual field Select from the return’s sections with sign-bearing manual fields — the API marks them basis: manual), taxable value, tax, reason; saves the whole list with revision. Actions gated: prepare/amend/adjustments/discard on taxreturns.return.manage, finalise on taxreturns.return.finalise, record payment on taxreturns.payment.create, transitions on taxreturns.payment.transition.

  • Step 3: Run — expect PASS. pnpm -C web test -- taxreturns && pnpm -C web lint && pnpm -C web typecheck.

  • Step 4: Commit. SST-02 Task 9: web — return drawer, adjustments, finalise, export, payment


Task 10: End to end on lane 8, roadmap, deploy notes, full suite

Section titled “Task 10: End to end on lane 8, roadmap, deploy notes, full suite”

Files:

  • Modify: web/e2e/seed.ts, web/e2e/support.ts
  • Create: web/e2e/tax-returns.spec.ts
  • Modify: docs/ROADMAP.md, docs/DEPLOY_NOTES.md

Interfaces:

  • Consumes: the running lane-8 stack; as, api, runTag from support.ts; AR’s e2e helpers (ensureFinanceAR).

  • Produces: ensureFinanceTaxReturns(): Promise<void>; the e2e flow.

  • Step 1: Seed and support. seed.ts: Finance gains the five taxreturns.* keys; the seeded org’s company profile keeps an empty SST number (the flow sets it). support.ts: ensureFinanceTaxReturns() in ensureFinanceAP’s shape.

  • Step 2: The e2e. web/e2e/tax-returns.spec.ts:

import { expect, test } from '@playwright/test';
import { api, as, ensureFinanceAR, ensureFinanceTaxReturns, runTag } from './support';
test.beforeAll(async () => {
await ensureFinanceAR();
await ensureFinanceTaxReturns();
});
// Dates: the registration starts on the 1st of the month two months before
// the current one, with one-month periods, so the previous month is a period
// that has ended (finalisable today) and the current month is open.
function cycle() {
const now = new Date();
const first = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 2, 1));
const firstEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 0));
const prevStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
const iso = (d: Date) => d.toISOString().slice(0, 10);
return { first: iso(first), firstEnd: iso(firstEnd), prevStart: iso(prevStart) };
}
test('registration → live draft from AR → finalise last period → export → pay → approve', async ({ browser }) => {
const tag = runTag();
const { first, firstEnd, prevStart } = cycle();
// 1. Finance sets up the registration (monthly cycle, so an ended period exists today).
const fin = await as(browser, 'finance');
await fin.goto('/tax-returns');
await fin.getByRole('button', { name: /Set up registration|Edit registration/ }).click();
const reg = fin.getByRole('dialog');
await reg.getByLabel('SST registration number').fill('W10-1808-32000001');
await reg.getByLabel('Effective from').fill(first);
await reg.getByLabel('First taxable period ends').fill(firstEnd);
await reg.getByLabel('Months per taxable period').fill('1');
await reg.getByRole('button', { name: 'Save' }).click();
// The flow assumes a fresh lane-8 database (dev-stack.sh with a new ERP_STATE
// dir): on a re-run the previous period is already finalised and "Prepare
// return" would be an amendment.
// 2. Arrange, not under test: an issued invoice (ST10 100 + SVT8 100) and a part receipt, through AR's API.
const inv = await api.issueSalesInvoice(fin, { tag, lines: [{ code: 'ST10', price: '100.00' }, { code: 'SVT8', price: '100.00' }] });
await api.recordReceipt(fin, { invoiceId: inv.id, amount: '104.00' }); // half of gross 218.00 is 109.00; 104 → share 104/218
// 3. The current period's draft computes live from AR.
await fin.getByRole('row', { name: /Not started/ }).first().click(); // newest first: the current month
await fin.getByRole('button', { name: 'Prepare return' }).click();
const drawer = fin.getByRole('dialog');
await expect(drawer.getByText('Draft — figures are recalculated')).toBeVisible();
await expect(drawer.getByRole('row', { name: /Taxable goods sold.*10/ })).toContainText('10.00'); // sales tax at issue
await expect(drawer.getByRole('row', { name: /Taxable services provided.*8/ })).toContainText('3.82'); // 8.00 × 104/218
// PRESENCE-before-ABSENCE: this period has not ended, so it cannot be finalised.
await expect(drawer.getByRole('button', { name: 'Finalise' })).toBeDisabled();
// 4. The previous (ended) period: a draft with an imported-services adjustment, finalised.
await fin.goto(`/tax-returns`);
await fin.getByRole('row', { name: new RegExp(prevStart) }).click();
await fin.getByRole('button', { name: 'Prepare return' }).click();
await fin.getByRole('button', { name: 'Edit adjustments' }).click();
const adj = fin.getByRole('dialog', { name: /adjustments/i });
await adj.getByRole('button', { name: 'Add adjustment' }).click();
await adj.getByRole('combobox', { name: 'Item' }).click();
await fin.getByRole('option', { name: 'Imported taxable services' }).click();
await adj.getByLabel('Taxable value').fill('1000.00');
await adj.getByLabel('Tax').fill('80.00');
await adj.getByLabel('Reason').fill(`${tag} cloud hosting invoice from overseas`);
await adj.getByRole('button', { name: 'Save' }).click();
await fin.getByRole('button', { name: 'Finalise' }).click();
await fin.getByRole('alertdialog').getByRole('button', { name: 'Finalise' }).click();
await expect(fin.getByText('Finalised')).toBeVisible();
// 5. The figures sheet exports.
const download = fin.waitForEvent('download');
await fin.getByRole('button', { name: /Export/ }).click();
await fin.getByRole('menuitem', { name: 'CSV' }).click();
expect((await download).suggestedFilename()).toMatch(/\.csv$/);
// 6. Finance records the payment; the preparer cannot approve it.
await fin.getByRole('button', { name: 'Record payment' }).click();
const pd = fin.getByRole('dialog', { name: /payment/i });
await expect(pd.getByLabel('Amount')).toHaveValue('80.00');
await pd.getByRole('combobox', { name: 'Paid from' }).click();
await fin.getByRole('option', { name: /1520/ }).click();
await pd.getByLabel('Payment reference').fill(`${tag} FPX`);
await pd.getByRole('button', { name: 'Submit' }).click();
await expect(fin.getByRole('button', { name: 'Approve and record payment' })).toHaveCount(0);
// 7. The owner approves; the period shows paid, nothing outstanding.
const own = await as(browser, 'owner');
await own.goto('/tax-returns?tab=payments');
await own.getByRole('row', { name: new RegExp(`${tag} FPX`) }).click();
await own.getByRole('button', { name: 'Approve and record payment' }).click();
await expect(own.getByText('Paid').first()).toBeVisible();
await own.goto('/tax-returns');
await expect(own.getByRole('row', { name: new RegExp(prevStart) })).toContainText('0.00'); // outstanding
});

Adjust selectors to the components as built (the AP e2e is the model). api.issueSalesInvoice / api.recordReceipt are arrange helpers built on the API (AR’s routes) in support.ts — if AR’s e2e already exported equivalents, use theirs. The service-tax figure: 8.00 × 104.00 / 218.00 = 3.8165… → 3.82; compute it again from the built rounding before relying on it. Run on lane 8:

Terminal window
export ERP_STACK=erp-l8 ERP_PG_PORT=56492 ERP_MINIO_PORT=59180 ERP_API_PORT=3180 ERP_WEB_PORT=5199 API_BASE=http://localhost:3180/v1
bash scripts/dev-stack.sh
pnpm -C web exec playwright test e2e/tax-returns.spec.ts

First run on a stack whose org predates this item: re-provision the org once (POST /v1/org/provision as owner) so it gains the tax_return_payment kind. Expected: PASS. Then run the whole e2e directory once on lane 8 (AP and AR flows must still pass).

  • Step 3: Docs. docs/ROADMAP.md — tick the SST-02 line and nest, in AP’s style:
- [x] SST-02 return generation
Design: [`2026-09-27-sst-02-design.md`](superpowers/specs/2026-09-27-sst-02-design.md) (D1–D16), migrations `00400`–`00403`
Plan: [`2026-09-27-sst-02.md`](superpowers/plans/2026-09-27-sst-02.md) (10 tasks, Rulings R1–R13)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — re-provision every organization (`tax_return_payment` workflow kind)
- Figures come from AR's per-line tax snapshot; the purchase side is not read (spec D6), so the
note below is answered for v1: no purchase-line snapshot until imported services are computed
- Service tax on payment basis with the 12-month rule (spec D5); a filed period that changes is flagged stale
- No MySST upload format: a figures sheet (CSV/XLSX/PDF) for manual entry (spec D12)
- Reconciliation with 2140 is a drift test (`internal/platform/taxreturn/reconcile_test.go`)
- End to end: registration → live draft → finalise → export → pay → approve (`web/e2e/tax-returns.spec.ts`)
- [ ] Open decisions (owner's call): the 13 lines of the spec's "For the owner to confirm";
SST-02 form references unverified until a tax agent maps them

Under AP’s open decision “SST-02 line identification … may a purchase code name 2140”, append: “— SST-02 reads sales snapshots only; recommends no purchase code name 2140 (SST-02 spec D6)”.

docs/DEPLOY_NOTES.md — an SST-02 entry: migrations 00400–00403; re-provision every organization (POST /v1/org/provision) so it gains the tax_return_payment workflow kind; no chart or tax-code change; existing Owner and Finance roles gain the five taxreturns.* keys through 00402; each SST-registered organization must set up its registration (effective date, first period end) on Tax returns before its first return; the registration screen writes the SST number to the company profile.

  • Step 4: Full suite, once. make generate && git status (clean) → go test -p 2 -count=1 ./... → pnpm -C web test && pnpm -C web lint && pnpm -C web typecheck. All green.

  • Step 5: Commit. SST-02 Task 10: e2e, roadmap, deploy notes


Spec coverage.

Spec Task
D1 generic engine, pack data, fields, unmapped kinds 1, 3, 4
D2 registration, SST number on the profile, freeze 2
D3 derived periods, due dates, periods list 1, 4
D4 document basis from snapshots, withholding by kind 3
D5 payment basis, 12-month rule, dating, telescoping 3
D6 no purchase snapshot (no task — recorded in ROADMAP, Task 10)
D7 draft/finalise/amend/discard, append-only lines 2, 4
D8 stale 4, 9
D9 adjustments 2, 4, 9
D10 payment kind, rule, outstanding, lock 5
D11 overdue, credit 4, 8
D12 figures sheet 7
D13 reconciliation 3, 5
D14 permissions 6
D15 labels 1, 6, 9
D16 retention, audit 2, 5

Known soft spots, flagged for the implementer: the TG_ARGV constraint name in 00400 (Task 2 Step 3 says what to do if plpgsql refuses it); accounting.NewPoster’s clock under e.At (Task 2 Step 7).