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.
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00400–00409only (declared already ininternal/kernel/db/migrationranges_test.go— do not edit it). This plan uses00400–00403. - Money never float.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (OpenAPIMoney); ratesmoney.Rate/NUMERIC(19,10)/ OpenAPIRate. Compare withCmp, never==. Nofloat64ininternal/platform/taxreturn,internal/modules/taxreturns,web/src/features/taxreturns(web/src/drift/nofloat.test.tsscans the web). - Every new table has
org_id UUID NOT NULL REFERENCES organization (id),ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_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 KEYisCONSTRAINT <table>_<rule>; no name ends in_check,_key,_excl(TestEveryConstraintIsNamed). A trigger refusal isRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on the name. - Pinned functions.
SET search_path = pg_catalog, public, pg_temp, schema-qualified tables, noSECURITY DEFINER. The payment guard reusespublic.payables_semantic(org, instance)from00302(it only reads an instance’s semantic; not renamed). - Every SQL statement names
org_id = $nfor each org-scoped table it touches, joins included, and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org fromtenancy.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/countrypacknames SST, LHDN, RMCD or MySST in Go identifiers or SQL (spec §11). The engine usespack_kind,basis, field keys. User-facing strings in web locale files may say “SST”; the MY pack’sNamesdo. - Accounting reacts to semantics. The payout rule is keyed
(tax_return_payment, payout); no Go or SQL compares astate_keyto a literal except the seeded graph and tests.taxreturn.Rules()is registered frominternal/modules/taxreturns/boot.go’sinit(). - 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 headerFOR UPDATE→ the period’s latest finalised returnFOR 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 passesdb.InTx’s error throughledger.MapErrorfirst (AP’s handlers do). - Business date is
clock.TodayUTC(c); everyDATEin Go is midnight UTC; nevertime.Now()in code under test. - Record-bearing text (amendment and discard reasons, adjustment reasons, payment bank reference) is English or Bahasa Malaysia:
recordtext.Checkin Go and a namedCHECK (… !~ '[-ヿ㐀-䶿一-鿿가-]')in SQL. - Errors are RFC 7807. Handlers
c.Error(apperr.New(kind, detail))and return. A field refusal is 422field=<name> rule=<code>: <sentence>(taxreturn.FieldError, AP’sFieldErrorshape copied). - Every route goes through
httpx.Protectedwith ataxreturns.*(orreports.*) permission and is inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces).api/public_routes.txtgains nothing.make generateafter every migration, OpenAPI,sqlc.yamlorconstants.yamlchange; generated files committed;git statusclean 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.tsrequires identical keys and placeholders. Malay is genuine Malay — use the strings in Task 8. The seeded graph carriesen,ms,zh(TestEverySeededGraphShipsAllThreeLocales); the pack’sNamescarry 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), nevert.Parallel(). - Tests:
testdb.Start(throughreceivablestest.New) per test; run only the touched packages while iterating (go test ./internal/platform/taxreturn/... -count=1); one fullgo 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>
Rulings
Section titled “Rulings”Plan-level choices the spec leaves open. Format: Ruling: <decision> — <why>.
- R1 Ruling: Vocabulary — return code
sst02(MY pack); tablestax_registration,tax_return,tax_return_adjustment,tax_return_line,tax_return_payment; workflow kind = journalsource_kind=taxreturn.KindPayment = "tax_return_payment"; audit entity kinds are the table names; statusesdraft | finalised | discarded; basesdocument | payment | manual; line source kindssales_invoice | customer_credit_note | adjustment. - R2 Ruling: Payment references
TXP-<FY>-NNNNfromnumbering.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; actionssubmit, approve_payment, reject, revise, withdraw, void— AP’sPaymentGraphwith the kind changed. Grants:paidandvoided→ 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 withAmount.MulDivCents; document-basis line sums are rounded per document per rate withAmount.Cents()(base amounts of a foreign invoice carry four places). Both helpers live in a new fileinternal/kernel/money/cents.go(AR editsrate.go; a separate file avoids the merge). - R6 Ruling:
GET /tax-returns/{id}on a finalised return recomputes live to reportstaleandlive_tax_payable; on a draft it computes live. No caching. - R7 Ruling: The registration
PUTwritescompany_profile.sst_registration_nothroughcompany.Update(read-modify-write in the same transaction, audited bycompany.Update) and the registration audit row last. - R8 Ruling:
tax_account_iddefault for a new registration = the single distinctaccount_idof the org’s activesales/bothtax codes with a positive rate; if none or several, the field is required (422tax_account). A valid tax account is active, postable,type = liability, nocontrol_kind, noowner_module. - R9 Ruling: The payment copies the registration’s
tax_account_idonto 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, andamount ≤ outstanding(period) − Σ amounts of the period's other payments in pending_approvalat submit;amount ≤ outstanding(period)again at approval under the lock. Outstanding counts payments whose instance semantic ispayout. - R11 Ruling: No reconciliation check is registered with
reconcilein 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, sotax_return_version_oncenever collides);amends_id= the period’s latest finalised version, or NULL when none — a reason is required exactly whenamends_idis set. - R12 Ruling (web): one nav item appended to the
accountinggroup (/tax-returns), permissiontaxreturns.return.read; two tabs (Periods, Payments); the return is aDrawer.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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. |
New Go packages
Section titled “New Go packages”| 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. |
Modified (shared)
Section titled “Modified (shared)”| 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(packagecountrypack, uses the file’s owntestPackhelpers fromtestpack_test.go): a valid def passes; each refusal after it — duplicate key, unknown basis,paymentbasis withPaymentLimitMonths ≤ 0,manualfield with aPackKind,document/paymentfield without one, two fields on one pack kind,Signoutside {−1, 0, 1},PeriodMonthsoutside 1–12,DueMonthsAfterEnd < 0, a missingzhname.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) andEXEMPT,OShas a field for itsPackKind(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 addfunc (pack) TaxReturn() countrypack.TaxReturnDef { return taxReturn }tomy.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.goandperiod.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
exemptfield fromtaxReturn:TestEverySeededSalesCodeHasAReturnFieldred namingEXEMPT. (b) Changeend.Month()+time.Month(dueMonths)toend.Month()+time.Month(dueMonths)+1:TestPeriodsFollowTheRegistrationsCyclered. 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); errorsErrNoTaxReturn,ErrNoRegistration,ErrRegistrationFrozen,ErrStaleRevision,FieldError{Field, Rule, Detail}; test worldtaxreturntest.EnvwithNew(t),e.Register(t, from, firstEnd string),e.At(t, day string). -
Step 1: Failing schema test
schema_test.go(raw SQL aserp_appthrough the world’s tx helpers, the APinvoice_schema_test.gopattern):
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_registrationdoes 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 StatementBeginCREATE 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 StatementEndCREATE TRIGGER tax_return_frozen BEFORE UPDATE ON tax_return FOR EACH ROW EXECUTE FUNCTION tax_return_frozen();
-- +goose StatementBeginCREATE 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 StatementEndCREATE 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 StatementBeginCREATE 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 StatementEndCREATE 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 DownDROP 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,Execlive intaxreturntest/env.go(below). -
Step 5: Failing registration tests
registration_test.go:TestARegistrationDefaultsFromThePackAndTheChart—PutRegistrationwithPeriodMonths: 0,TaxAccountID: nil, registration noW10-1808-32000001: stored with2, the id of account2140;company.Getnow has the SST number; anaudit_logrow forcompany_profileand one fortax_registration.TestTheTaxAccountMustBeALiabilityWithoutControl— presence:2140accepted; then2105(controlap_trade),4100(income) → 422tax_account.TestAnOrgWithoutAReturnPackHasNoTaxReturn— switch the world’s company profile country to one with no pack (the countrypacktestpackpattern) →ErrNoTaxReturn; presence: MY answers the def.TestRegistrationPeriodsFreezeAfterAFinalisedReturn— presence:PutRegistrationchangingfirst_period_endsucceeds;e.RawFinalisea return; the same change →ErrRegistrationFrozen(mapped fromtax_registration_period_frozen); changing onlytax_account_idstill succeeds.TestAStaleRevisionIsRefused— revision 1 twice: second →ErrStaleRevision.
-
Step 6: Implement
errors.go,registration.go.FieldErroris AP’s (payables.FieldError) copied with itsError()format.Definition:company.Get→countrypack.Lookup(profile.CountryCode)→p.(countrypack.ReturnPack)orErrNoTaxReturn.GetRegistration:SELECT … FROM tax_registration WHERE org_id = $1 AND return_code = $2,RegistrationNofromcompany.Get,Frozen=EXISTSfinalised return.PutRegistration(ctx, tx, c, actor, in): validate in Go (effective_fromrequired;first_period_endmonth end, ≥ from, within months — the same three rules the CHECKs enforce, asFieldErrorsmonth_end,first_period_within;period_months0 ⇒def.PeriodMonths, else 1–12period_months;registration_no1–40 chars,^[A-Za-z0-9-]+$registration_no; tax account per R8), thenINSERT … ON CONFLICT (org_id, return_code) DO NOTHINGfor a new one, orSELECT … FOR UPDATE+ revision check +UPDATE … revision = revision + 1for an existing one (maptax_registration_period_frozen→ErrRegistrationFrozen), thencompany.Updatewith the SST number changed (skip when unchanged), thenaudit.Write(entitytax_registration) last.(Registration).Periods(def, through)callsPeriods(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'toIF false:TestTheReturnFreezesOnceFinalisedred attax_return_frozen. (b) Remove theEXISTSguard’s condition (always pass):TestRegistrationPeriodsFreezeAfterAFinalisedReturnred. (c) Droptax_return_linefromretention_test.go’sappendOnlylist: 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.gotest-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(packagetaxreturn, 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"), today2026-09-21, period2026-09-01…2026-10-31):TestSalesTaxIsReportedByRateOnTheDocumentDate— invoice A: ST10 1000.00 + ST5 200.00; credit note on A: ST10 100.00. Period figures:sales_taxrows10→ 900.00 / 90.00,5→ 200.00 / 10.00;SourceLines: A (two rates) and the credit note (negative). PRESENCE-before-ABSENCE: an invoice issued ate.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_tax8→ 50.00 / 4.00;Reconciliation.Deferred= 4.00.TestExemptAndOutOfScopeAreInformationOnly— linesEXEMPT300 andOS50: fields report them;TaxPayableexcludes them (presence: an ST10 line in the same invoice counts).TestOnlySalesCodesReachTheReturn— AR refuses a purchase code (and, once WHT has merged, awithholdingcode) 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 incompute.go.TestAnUnmappedKindIsReported— a sales code withpack_kind = 'tourism_tax'on an issued line →Unmapped == ["tourism_tax"]; presence: without it,Unmappedempty.TestAdjustmentsCountWithTheirSign—imported_services1000/80,deduction0/10 →TaxPayable= 80 − 10 + document tax.TestReconciliationIsZeroForDocumentBasisOnly— ST10 and ST5 invoices and a credit note only:Differencezero; PRESENCE of the mechanism: a manual journalDr 6190 / Cr 2140 12.00in the period (throughledger.Postin the world) makesDifference12.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)- 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_rateFor 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.
- 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 endSELECT 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 rateSELECT 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.
-
Adjustments →
SourceLine{SourceKind: "adjustment", AdjustmentID, Reference: "ADJ " + reason[:min(40)]}, amounts as entered; each addsSign × taxtoexpected. -
Fields — group
Linesby field key and (forByRate) rate intoFieldFigures indef.Fieldsorder (a field with no lines still appears, zero).TotalTax= Σ tax of fields withSign ≠ 0(unsigned: the gross tax figure the form’s total asks for, adjustments included with their sign);TaxPayable= ΣSign × Tax. -
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;TestServiceTaxIsReportedAsItIsPaidextended with a credit note goes red (over-attribution). (b) Replacegreatest(r.receipt_date, i.invoice_date)witha.effective_on: addTestAReceiptIsDatedByItsReceiptDate(receipt recorded2026-11-02withreceipt_date2026-10-31→ counted in Sep–Oct) and watch it go red. (c) Flip the credit-note sign:TestSalesTaxIsReportedByRateOnTheDocumentDatered. 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}; funcsCreateReturn(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 inpayout— returns zero until Task 5 creates the table; Task 5 fills it in); errorsErrNotAPeriod(422not_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;ViewReturnincludes it (live).TestFinaliseWaitsForThePeriodToEnd— Jul–Aug draft at today2026-09-21: finalise succeeds (presence); Sep–Oct draft at today →ErrPeriodNotEnded; aftere.At(t, "2026-11-01")it succeeds.TestFinaliseFreezesTheFigures— finalise with an invoice and an adjustment;tax_return_linerows = the computation’sSourceLines;registration_no,total_tax,tax_payablesnapshotted; issue another invoice dated in the period — impossible after the period (no back-dating), so instead record a receipt withreceipt_datein the period for a service-tax invoice:ViewReturnstill shows the frozen figures andStale == 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_taxas a field → 422adjustment_field; tax 0 → 422adjustment_amount; reason咖啡→ 422record_language; presence:imported_servicesaccepted; after finalise →ErrNotDraft.TestDiscardedDraftsAreKept— discard v1 with a reason; the row remains,status = discarded; a new draft is version 2 withamends_idNULL (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, today2026-09-21: five periods, newest first; Jul–Aug (due2026-09-30) not overdue; May–Jun (due2026-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(elseErrNotAPeriod); read the period’s rowsORDER BY version DESC; if adraftexists →ErrDraftExists;versionandamends_idper 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.Writelast. Maptax_return_one_drafttoErrDraftExiststoo (the race).SetAdjustments:lockReturn,Status == draftelseErrNotDraft, revision check, validate each (def.Field(key)withBasis == manual; amounts ≥ 0 / > 0 and in sen; reason record text),UPDATE … SET withdrawn_at = nowfor 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 →Computelive; finalised → figures fromtax_return_line(grouped exactly asComputegroups, one sharedgroup(def, lines)helper), plus a liveComputeforStale(live.TaxPayable ≠ frozenor any field/rate differs) andLiveTaxPayable.Paid/Outstandingper period viapaidFor(latest finalised version’s payable − paid);Overdueper spec D11.Versions: every row of the period, newest first. -
SourceLines: frozen lines for a finalised return,Compute(...).Linesfor a draft, filtered by field key. -
ListPeriods:reg.Periods(def, today)reversed; one query for all rows of the registration; onepaidForper period that has a finalised return (≤ a few dozen periods; no N+1 worth fixing).Staleis 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:TestFinaliseWaitsForThePeriodToEndred. (b) MakeViewReturnreturnStale: falsealways:TestFinaliseFreezesTheFiguresred 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; errorsErrReturnNotFinalised,ErrOverOutstanding(422amount_over_outstanding),ErrBankAccount(422bank_account),ErrInstanceMismatch. -
Step 1: Migration
00401_tax_return_payment.sql—supplier_payment’s shape (00304) with:return_id(FKtax_return),tax_account_id(FKaccount), no supplier, no currency/fx (base only),amount NUMERIC(19,4) > 0with a cents CHECK,payment_date DATE,bank_reference(≤ 64, record language),referenceunique,instance_idunique (FKworkflow_instance),prepared_by,revision, timestamps; RLS ×4;REVOKE DELETE;tax_return_payment_draft_guardonUPDATE OF return_id, bank_account_id, tax_account_id, payment_date, bank_reference, amountviapublic.payables_semantic(00304’s function copied with the table/constraint renamed, constrainttax_return_payment_is_draft). Retention:amendable. A schema test (presence: a draft payment’samountupdates; after the instance leaves draft, the same UPDATE →tax_return_payment_is_draft). -
Step 2: Graph.
graphs.go— AP’sPaymentGraph(internal/platform/payables/graphs.go) copied withKind: KindPaymentand the helpersls/st/ed/why/seatcopied (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 animported_servicesadjustment 1000/80, so its payable is 80.00):TestAPaymentPostsDrTaxAccountCrBank— create 80.00 from1520→ submit (Staff) → approve (Owner): the entry hasDr 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) → 422amount_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 triesapprove_payment→ the engine’s self-approval refusal; presence: Owner can.TestVoidReversesAndRestoresOutstanding— void (Owner, reason) → reversal entry;Outstandingback to 80.00.TestTheBankAccountMustBeABankControlInBase—2140→ErrBankAccount; presence1520.
-
Step 4: Implement
payment.go,posting.go.CreatePaymentmirrorspayables.CreatePayment(numberingTXP,workflow.Start{Kind: KindPayment, SourceID: id, Reporter: actor, Amount: in.Amount}), withcheckPayment:
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, animported_servicesadjustment 1000/80 with its journalDr 6190 80 / Cr 2140 80posted throughledger.Postas the preparer would), finalise, pay:Reconciliation.Differenceis 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 to2150instead of2140→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:TestApprovalRechecksAfterAnAmendmentred. (b) Creditp.TaxAccountIDinstead 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 mirroringpayables/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.gofiles (mirrorinternal/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
taxreturnfunction 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.yamlgains the five keys afterpayables.payment.transition;make generate.permissions.goregisters 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”) andprovisioning.RegisterRolePermissions("Finance", …all five…, "accounting.ledger.read").boot.goregisterstaxreturn.Rules()in one loop (AP’s comment).00402=00305with the five keys for both Owner and Finance (no Owner-only key), theperm_versionbump, and a Down that deletestaxreturns.%. -
Step 2: OpenAPI first. Add the paths and schemas of spec §API. Money fields
$ref: Money, ratesRate, datesDate. TagsTaxReturns. ThePUT /tax-returns/registrationdescription 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/registration404no_registrationthen 200 after PUT;POST /tax-returns422not_a_periodon2026-03-02; finalise 409period_not_ended; the error mapping table inerrors_test.gocovers every sentinel of Tasks 2–5 (FieldError → 422 withfield/rule;ErrDraftExists,ErrRegistrationFrozen,ErrPeriodNotEnded,ErrUnmappedKind,ErrRegistrationNoMissing,ErrNotDraft,ErrStaleRevision,ErrReturnNotFinalised→ 409 with the code as the problem’scode;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 throughledger.MapError. The return DTO: sections fromdef.Fieldsin order withlabels{en,ms,zh}= the field’sNames,form_ref,form_ref_verified = def.FormRefsVerified,sign;rowswithdocumentscounts;unmapped;reconciliation;adjustments;versions;payments(ListPayments{ReturnID}for every version of the period). -
Step 5: Wiring. Compose the module exactly where and how
payablesis composed ininternal/app/app.goand the fourcmd/*/main.go(the worker and migrate binaries import it for itsinit()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 statusclean. -
Step 7: Sabotage. Drop
httpx.ProtectedfromPOST /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.
00403rebuildsreport_export_report_knownwith every report on the branch at build time plustax_return(read the latestreport_export_report_knownmigration on the branch; program ruling: the controller re-unions at merge). Down restores that list. -
Step 2: Failing tests.
params_test.go:tax_returntakes onlyreturn_id(required, 422InvalidParamsError{Field: "return_id"}when absent;fromrefused as “not taken by tax_return”).export.Createwith an unknown return id →InvalidParamsError{Field: "return_id"}; the worker treatstaxreturn.ErrNotFoundasinvalid_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 notedef.SheetNote(pack data, because it names the portal). A draft’s title is prefixedDRAFT — not filed. The export package’s own CHECK drift test acceptstax_return. -
Step 3: Implement.
export.go: the constant and title “Tax return”;params.go:allowed[ReportTaxReturn] = {"return_id": true},present()andValidatecases (theas_ofbranch’s shape, keyed onreturn_id);project.go:projectTaxReturn—ViewReturn,metawith the return’s own fields appended (metareadsx.Report; add a branch that skips From/To fortax_return), columnsForm reference(w 10),Item(w 14),Rate %(w 4, numeric),Taxable value(money),Tax(money); rows viarender.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:ReportNameenum gainstax_return,ExportParams.return_id.make generate. -
Step 4: Run — expect PASS.
go test ./internal/platform/reporting/... -count=1. -
Step 5: Sabotage. Remove
tax_returnfrom00403’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 (whereverpayablesis 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(mirrorweb/src/features/payables/api.ts: query keys, invalidation ofperiodsand 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 showsempty.noRegistrationon 404no_registrationandempty.noReturnon 404no_tax_return; with a registration it lists periods newest first with status andOverduetags; Prepare return on anonerow callsPOST /tax-returnswith thatperiod_start; the registration dialog prefills and PUTs, showsregistration.frozenand disables the three period fields whenfrozen; the payments tab lists payments with a state filter; nofloat(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-returnswith?tab=and?return=<id>search params (the drawer is Task 9; render nothing forreturnyet).TaxReturnsPage:PageHeader, registration card (number, effective, cycle, account; Set up/Edit gated ontaxreturns.return.manage),Tabs(periodsDataTable tableId="taxreturns.periods"with period as the always-shown identifier and tax payable as the amount; paymentsDataTable tableId="taxreturns.payments"). Row click sets?return=(latest id) or, fornone, 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 threetaxreturns.json
Interfaces:
-
Consumes: Task 8’s hooks,
ExportMenu(web/src/features/reports/ExportMenu.tsx, reporttax_return, params{ return_id }),ConfirmDialog,Drawer, the payment transition shape ofweb/src/features/payables/PaymentDrawer.tsx. -
Step 1: Failing tests: a draft drawer shows
banner.draft, sections in order with each rate row andformRef.unverifiedbeside each reference whenform_ref_verifiedis 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’sfinalisableflag, computed server-side: draft, period ended, no unmapped kinds); a finalised drawer shows Amend (reason required) and, whenstale,banner.stalewithlive_tax_payable; the rows’ document count opensReturnLines(GET …/lines?field_key);ExportMenurequeststax_returnwithreturn_id; Record payment opens the dialog prefilled withoutstanding, bank accounts fromGET /receivables/deposit-accountsfiltered to control kindbank(or the chart’s bank accounts endpoint AP’sPaymentDialoguses — use AP’s), and posts; the payment drawer shows the available transitions (AP’sPaymentDrawerpattern, the reason prompt on void/reject). -
Step 2: Implement. Labels come from
section.labels[i18n.language] ?? section.labels.en. Money formatted withweb/src/lib/money.ts, neverNumber.AdjustmentsDialog: rows of (manual fieldSelectfrom the return’s sections withsign-bearing manual fields — the API marks thembasis: manual), taxable value, tax, reason; saves the whole list withrevision. Actions gated: prepare/amend/adjustments/discard ontaxreturns.return.manage, finalise ontaxreturns.return.finalise, record payment ontaxreturns.payment.create, transitions ontaxreturns.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,runTagfromsupport.ts; AR’s e2e helpers (ensureFinanceAR). -
Produces:
ensureFinanceTaxReturns(): Promise<void>; the e2e flow. -
Step 1: Seed and support.
seed.ts: Finance gains the fivetaxreturns.*keys; the seeded org’s company profile keeps an empty SST number (the flow sets it).support.ts:ensureFinanceTaxReturns()inensureFinanceAP’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:
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/v1bash scripts/dev-stack.shpnpm -C web exec playwright test e2e/tax-returns.spec.tsFirst 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 themUnder 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
Self-Review
Section titled “Self-Review”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).