Skip to content

MyInvois and Self-billed e-Invoice 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 organization that is in LHDN’s e-Invoice scope switches “e-Invoice in scope” on; from then every sales invoice and customer credit note it issues, and every self-billed purchase invoice or supplier credit note it approves, is checked against MyInvois’s rules in the issuing transaction, queued in that same transaction, submitted by the worker (signed when a certificate is configured), polled until LHDN validates or refuses it, retried with backoff on transient failures inside the 72-hour window, and can be cancelled at LHDN within 72 hours of validation — which voids the document and reverses its entry. The invoice PDF carries the validated UUID, the validation link and its QR code. Everything is tested against a local fake MyInvois server; nothing in CI touches LHDN.

Architecture: internal/platform/einvoice is country-neutral: the neutral Document, the Codec/Client interfaces, einvoice_setting and einvoice_submission, the workflow guards for the four document kinds, the submission state machine, and the River sweep. internal/platform/countrypack/my/myinvois is LHDN: code tables, Check, the UBL 2.1 JSON encoder, the signer, the HTTP client and its error classification, and myinvoisfake. The workflow engine gains one extension point, workflow.RegisterGuard, so AR and AP never import einvoice. internal/modules/einvoice is the HTTP shell (settings, submissions list, retry, cancel) and the place the Codec is registered. The worker runs a 15-second sweep that reaches tenants through one allowlisted SECURITY DEFINER function returning organization ids only. accounting/ is untouched; a cancellation is the document’s own reversing workflow edge.

Tech Stack: Go 1.26 · Gin · pgx v5 · goose · oapi-codegen · River · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money · crypto/rsa, crypto/x509 (stdlib) · github.com/boombuler/barcode/qr (already in the module graph) · React 19 · TanStack Router/Query/Table · i18next (en/ms/zh) · Vitest + MSW · Playwright

Spec: docs/superpowers/specs/2026-09-27-myinvois-design.md (D1–D22). Builds on: docs/superpowers/plans/2026-09-27-accounts-receivable.md (must be merged into phase-2 before Task 4) and the merged AP plan docs/superpowers/plans/2026-09-26-phase-2-ap.md. Where a step says “mirror AP” or “mirror AR”, open the named file and follow it, changing only what the step names.

Every task’s requirements implicitly include this section.

  • Migrations 00380–00399 only. This plan uses 00380–00382. The range is already declared in internal/kernel/db/migrationranges_test.go ({380, 399, "Phase 2 MyInvois and self-billed e-Invoice"}) — do not edit that file.
  • Money never float. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on our API. LHDN’s UBL JSON wants JSON numbers: they are written from the decimal text as json.Number, never through float64 (spec D14). internal/platform/einvoice, internal/platform/countrypack/my/myinvois, internal/modules/einvoice and web/src/features/einvoice join the no-float scans (internal/platform/reporting/render/nofloat_test.go’s pattern for Go; web/src/drift/nofloat.test.ts for the web). Compare with Cmp, never ==.
  • Every new table has org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, a tenant-id UNIQUE (org_id, id) (or org_id as the primary key, the company_profile shape), and composite (org_id, x) foreign keys only.
  • Named constraints. Every CHECK/UNIQUE/FOREIGN KEY is CONSTRAINT <table>_<rule>; no name ends in _check, _key, _excl. Trigger refusals: RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'.
  • Pinned functions. SET search_path = pg_catalog, public, pg_temp, schema-qualified tables. The one SECURITY DEFINER function, einvoice_due_orgs(), is added to securityDefiners in internal/kernel/db/securitydefiner_test.go with its reason, REVOKE ALL … FROM PUBLIC, GRANT EXECUTE … TO erp_app, and refuses to run unless its owner bypasses RLS (the 00271 shape).
  • Every SQL statement in Go names org_id = $n for each org-scoped table it touches, joins included; every INSERT names org_id. Platform functions read the org from tenancy.OrgFromContext.
  • Retention. internal/kernel/db/retention_test.go: einvoice_setting, einvoice_submission are amendable (REVOKE DELETE).
  • Accounting reacts to semantics. Guards decide by posting.Semantic of Move.From/Move.To, never by state key. The only literals are the seeded graphs and tests.
  • Modules never write journal lines. A cancellation is the document’s reversing edge; nothing here posts.
  • Lock order (added to AR’s and AP’s): document header FOR UPDATE (the caller) → … → workflow.Transition (instance lock → guard → decision → Poster) → caller’s audit.Write, last. A guard never calls audit.Write (it runs before the Poster’s advisory lock). In the worker: submission rows FOR UPDATE SKIP LOCKED → updates → audit.Write, last; no network call is made inside a transaction.
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …); only handlers and the worker open transactions. Handlers whose transaction can post pass db.InTx’s error through ledger.MapError before mapErr.
  • Business date is clock.TodayUTC(c); timestamps from c.Now(); never time.Now() in code under test. The fake takes a clock.Clock too.
  • Record-bearing text (seller business activity and address, cancellation reason) is English or Bahasa Malaysia: recordtext.Check in Go and a named script CHECK (… !~ '[぀-ヿ㐀-䶿一-鿿가-힯]') in SQL.
  • Errors are RFC 7807. Platform code returns sentinels or typed errors: einvoice.FieldError{Field, Rule, Detail} (422, field=<name> rule=<code>: <sentence>), einvoice.CheckError{Problems []FieldError} (422, all at once), einvoice.ConflictError{Code, Detail} (409 with code). The AR and AP modules’ mapErr map them (Task 8).
  • Every route goes through httpx.Protected with an einvoice.* permission and is in api/openapi.yaml in the same commit. api/public_routes.txt gains nothing. make generate after every migration, OpenAPI or constants.yaml change; generated files committed; git status clean after it.
  • Environment. Every variable a binary reads is in .env.example (internal/kernel/config/envexample_test.go). Sandbox credentials are read only by the build-tagged smoke test, and are listed in .env.example too.
  • Tests never reach LHDN. Go tests use httptest.NewServer(myinvoisfake.New(...)); the e2e uses cmd/myinvois-fake. The only test that talks to LHDN is behind //go:build myinvois_sandbox and is never run in CI.
  • i18n. Every UI string is a key in web/src/lib/i18n/locales/{en,ms,zh}/einvoice.json (or the named existing namespace); i18nparity.test.ts requires identical keys and placeholders. Malay is genuine Malay — the strings in this plan are the ones to use. Seeded workflow labels carry en, ms, zh.
  • Presence before absence (AGENTS.md). Every refusal test shows the accepted case first. Every guard, drift test and trigger is watched failing once: each task with one has a Sabotage step — break it, run the test, read the red message, write it into the commit body, restore.
  • Posting is process-global. A test that needs a Poster uses the test world’s UsePoster-style helper (receivablestest, payablestest), never t.Parallel(). The same applies to the Codec/Client registries: tests that register use einvoicetest.Use(t, codec, client) which restores on cleanup, never t.Parallel().
  • Tests: testdb.Start per test (through the existing test worlds); run only touched packages while iterating; one full go test -p 2 -count=1 ./... in Task 12 only.
  • E2E on lane 7 only: ERP_STACK=erp-l7 ERP_PG_PORT=56482 ERP_MINIO_PORT=59170 ERP_API_PORT=3170 ERP_WEB_PORT=5198 ERP_MYINVOIS_FAKE_PORT=3177. The port overrides already exist in scripts/dev-stack.sh, web/playwright.config.ts, web/vite.config.ts; only the fake is new. Never the user’s dev stack (55432/59000/3000/5173).
  • Never bare git stash. Set work aside with a WIP commit.
  • Git commits end with: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

  • M1 Ruling: Package names — einvoice (platform and module), myinvois (the pack’s sub-package internal/platform/countrypack/my/myinvois), myinvoisfake, einvoicetest; tables einvoice_setting, einvoice_submission; audit entity kinds are the table names — one vocabulary.
  • M2 Ruling: Submission statuses queued, submitting, submitted, valid, invalid, cancelled, failed; failure codes window_expired, rejected_structure, forbidden, too_large, duplicate_unresolved, not_configured; document types 01, 02, 11, 12 — strings, a named CHECK each, Go constants in einvoice.
  • M3 Ruling: workflow.RegisterGuard(kind, Guard) panics on a bad key, a nil guard or a second registration (the RegisterSeededGraph rules); workflow.HasGuard(kind) exists for the cmd wiring tests. The guard runs in Transition after the sign-off early return (a partial sign-off never runs it) and before the instance moves.
  • M4 Ruling: einvoice registers its four guards from init() in internal/platform/einvoice/guard.go; internal/modules/einvoice/boot.go registers the MY Codec. Every binary that links a module that transitions (cmd/{api,migrate,orgadmin,worker}) links internal/modules/einvoice, and each wiring_test.go asserts workflow.HasGuard for the four kinds and einvoice.HasCodec("MY").
  • M5 Ruling: A guard with the organization out of scope returns nil for every sales move except the void (spec D10: correct_by_credit_note still applies — a sales void needs a submission row), and for a purchase document with basis self_billed refuses 422 self_billed_needs_scope.
  • M6 Ruling: The guard builds the neutral Document from receivables.GetDocument (issued: the snapshot; not yet issued, i.e. a credit note’s submit: overlaid with receivables.GetCustomer and company.Get) or payables.GetInvoice + payables.GetSupplier + company.Get, plus einvoice.GetSetting.
  • M7 Ruling: Sweep period 15 s (river.PeriodicInterval(15*time.Second), RunOnStart), queue jobs.QueueEInvoice = "einvoice" with MaxWorkers: 1. Organizations are processed one after another in a sweep, each in its own transactions. ponytail: one queue worker processes every due org serially; fan out to one River job per org when a sweep takes longer than its period.
  • M8 Ruling: Backoff min(30s × 2^(attempts−1), 30m); Retry-After (seconds) wins when larger. The window closes at issued_at + 72h − 15m; a next attempt past it makes the row failed/window_expired. Polling a submitted row: 5 s, 10 s, 20 s … capped at 5 m; the first poll 3 s after submitting, inside the same sweep (time.Sleep via an injectable Pause func(time.Duration), a no-op in tests).
  • M9 Ruling: The window watch re-reads a valid row every 15 minutes until validated_at + 72h, then sets next_check_at NULL. A cancelled/invalid/failed row has next_check_at NULL.
  • M10 Ruling: Bytes are signed once, at the first attempt, and stored (submitted_document, write-once). A retry or a resend after an unknown outcome sends the stored bytes, so a resent document is byte-identical and LHDN’s 10-minute duplicate rule is the only duplicate protection needed within it.
  • M11 Ruling: The HTTP client has a 30 s timeout; token cache key = the onbehalfof value; a 401 drops that key’s token once and retries the call once before classifying.
  • M12 Ruling: code_number = our document reference (INV-2026-0001, AP’s PI-…/CN-… reference); LHDN’s internalId is matched against it for unknown-outcome recovery.
  • M13 Ruling: Cancel’s rehearsal is db.InTx returning the sentinel errRehearsal after a successful transition, so the transaction rolls back; any other error is the refusal, mapped as the document’s own transition error.
  • M14 Ruling: GET /einvoice/submissions is keyset-paginated on (created_at DESC, id DESC) (AP’s cursor.go, copied), filters status, source_id; limit ≤ 100.
  • M15 Ruling: A state name (buyer_address_state, supplier address_state) maps to a code through the pack’s table: the 17 codes themselves, the SDK names, and common English forms (Penang, Malacca, Kuala Lumpur, Labuan, Putrajaya, Negri Sembilan), case- and space-insensitive. A non-MY country maps to 17.
  • M16 Ruling: Classification default on the web — AR lines keep AR’s 022; AP self-billed lines default to 036.
  • M17 Ruling: Provisioning step order 78 (receivables.StepOrderSalesGraphsV2); WHT claims 77 — the controller checks the other lanes at merge.
  • M18 Ruling: The QR is drawn 72 pt square at the page’s top-right margin on page 1 (Meta.QR); Meta.Fields keep their existing layout.

File Task Responsibility
00380_einvoice.sql 4 einvoice_setting, einvoice_submission (+ freeze trigger), einvoice_due_orgs().
00381_payables_self_billed.sql 7 self_billed basis, purchase_invoice.original_invoice_id, purchase_invoice_line.classification_code, freeze guard column list.
00382_einvoice_role_grants.sql 8 Existing Owner (3 keys) and Finance (2 keys) roles.
Path Task(s) Responsibility
internal/platform/einvoice/ 1, 4, 6, 7 doc.go (types, constants, errors), registry.go (Codec/Client), setting.go, submission.go, build.go (neutral Document from AR/AP), guard.go, guard_payables.go, worker.go, cursor.go
internal/platform/einvoice/einvoicetest/ 4 Test world on receivablestest.Env + the fake.
internal/platform/countrypack/my/myinvois/ 2, 3 codes.go, check.go, ubl.go, number.go, sign.go, client.go, errors.go, testdata/*.json, sandbox_test.go (build tag)
internal/platform/countrypack/my/myinvois/myinvoisfake/ 3 The fake server.
internal/modules/einvoice/ 8 boot.go, permissions.go, routes.go, http.go, errors.go, setting_handlers.go, submission_handlers.go
cmd/myinvois-fake/ 3 main.go — the fake as a process, for the e2e.
web/src/features/einvoice/ 10 API hooks, EInvoicePage, SubmissionsTab, SettingsTab, EInvoicePanel, CancelDialog.
Path Task Change
internal/platform/workflow/guard.go (new) + instance.go 1 Guard registry; the call in Transition.
internal/kernel/config/myinvois.go (+ test), .env.example 3 MyInvoisFromEnv.
internal/kernel/jobs/jobs.go 6 QueueEInvoice.
internal/kernel/db/securitydefiner_test.go, retention_test.go 4 Allowlist einvoice_due_orgs; amendable lists.
internal/platform/receivables/graphs.go, graphs_test.go, step.go (new), transition.go 5 v2 graphs; step 78; allocations refuse the void.
internal/platform/payables/{invoice.go,supplier.go,doc.go} + tests 7 self_billed, classification_code, original_invoice_id, supplier registration_type.
internal/modules/payables/{invoice_handlers.go,supplier_handlers.go} 7 DTO fields.
internal/modules/receivables/errors.go, internal/modules/payables/errors.go 8 Map einvoice errors.
internal/platform/reporting/render/{render.go,pdf.go} (+ test) 9 Meta.QR.
internal/modules/receivables/pdf.go (+ test) 9 UUID, link, QR.
go.mod 9 github.com/boombuler/barcode indirect → direct.
constants.yaml (+ generated vocab) 8 Three permissions.
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) 7, 8 AP fields; /einvoice/….
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/*/wiring_test.go 6, 8 Compose the module; the worker’s sweep.
web/src/app/shell/nav.ts, web/src/app/routes.ts 10 One nav item, one route.
web/src/features/receivables/*Drawer.tsx, web/src/features/payables/InvoiceDrawer.tsx 10 Mount EInvoicePanel.
web/src/features/payables/{InvoiceDialog.tsx,SupplierDialog.tsx,api.ts} 11 Self-billed basis, classification, original, registration type.
scripts/dev-stack.sh 12 Start the fake when ERP_MYINVOIS_FAKE_PORT is set.
web/e2e/einvoice.spec.ts, web/e2e/support.ts 12 The e2e.
docs/ROADMAP.md, docs/DEPLOY_NOTES.md 12 Tick both lines; the go-live checklist.

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 (7 → 8).

Wave Tasks Why this grouping
1 1 The engine guard and the neutral types everything else compiles against.
2 2 ∥ 3 2: codes, checks, UBL encoder (myinvois/{codes,check,ubl,number}.go). 3: signer, client, config, fake, cmd/myinvois-fake (myinvois/{sign,client,errors}.go, myinvoisfake/, kernel/config). Disjoint files in one package.
3 4 Requires AR merged. Migration 00380, settings, submissions, sales guards, test world.
4 5 ∥ 6 ∥ 7 5: platform/receivables (graphs v2, step, void check). 6: einvoice/worker.go, kernel/jobs, cmd/worker. 7: platform/payables, einvoice/guard_payables.go, 00381, AP module DTOs, OpenAPI (lane). Disjoint.
5 8 ∥ 9 8: modules/einvoice, 00382, OpenAPI (lane), app/cmd wiring, both modules’ errors.go. 9: render, modules/receivables/pdf.go, go.mod. Disjoint.
6 10 ∥ 11 10: web/src/features/einvoice, nav, drawers’ panel. 11: AP dialogs. Both need 8’s client.
7 12 Fake in dev-stack, e2e on lane 7, roadmap, deploy notes, full suite.

Task 1: The workflow guard and the neutral e-Invoice types

Section titled “Task 1: The workflow guard and the neutral e-Invoice types”

Files:

  • Create: internal/platform/workflow/guard.go, internal/platform/workflow/guard_test.go
  • Modify: internal/platform/workflow/instance.go (one call in Transition)
  • Create: internal/platform/einvoice/doc.go, internal/platform/einvoice/registry.go, internal/platform/einvoice/registry_test.go

Interfaces:

  • Produces: workflow.Move, workflow.Guard, workflow.RegisterGuard, workflow.HasGuard, workflow.UnregisterGuardForTest; einvoice.Document, Party, Line, Reference, Taxpayer, Outgoing, SubmitResult, Accepted, Rejected, DocStatus, Codec, Client, RegisterCodec, CodecFor, HasCodec, UseClient, CurrentClient, FieldError, CheckError, ConflictError, TransientError, PermanentError, ErrNotConfigured, the status / type / failure constants (M2).

  • Step 1: Failing guard tests. guard_test.go (package workflow_test) on the existing workflowtest world and its test kind (mirror internal/platform/workflow/instance_test.go’s setup: publish a graph draft → pending → approved (accrual) for a test kind, start an instance):

// PRESENCE: without a guard the move succeeds. ABSENCE: a refusing guard
// aborts it — no decision row, the instance stays where it was.
func TestAGuardRefusalAbortsTheMove(t *testing.T) {
w := workflowtest.New(t)
id := w.StartInstance(t, "guarded_kind")
w.Transition(t, id, "draft", "pending", w.Reporter) // presence: unguarded
refuse := errors.New("not today")
var seen workflow.Move
workflow.RegisterGuard("guarded_kind", func(ctx context.Context, tx pgx.Tx, c clock.Clock, m workflow.Move) error {
seen = m
return refuse
})
t.Cleanup(func() { workflow.UnregisterGuardForTest("guarded_kind") })
_, err := w.TryTransition(t, id, "pending", "approved", w.Approver)
require.ErrorIs(t, err, refuse)
require.Equal(t, "pending", w.State(t, id))
require.Equal(t, 1, w.DecisionCount(t, id))
require.Equal(t, posting.Pending, seen.From.Semantic)
require.Equal(t, posting.Accrual, seen.To.Semantic)
require.Equal(t, w.SourceOf(t, id), seen.SourceID)
}
// A partial sign-off records a share and does not move: the guard must not
// run (it would queue an e-Invoice for a document that is not approved).
func TestAPartialSignoffDoesNotRunTheGuard(t *testing.T) { /* signoff kind fixture from signoff_test.go; guard counts calls; two shares, one signed: 0 calls; second: 1 call */ }
func TestRegisterGuardRefusesMistakes(t *testing.T) {
g := func(context.Context, pgx.Tx, clock.Clock, workflow.Move) error { return nil }
require.Panics(t, func() { workflow.RegisterGuard("Bad-Key", g) })
require.Panics(t, func() { workflow.RegisterGuard("ok_kind", nil) })
workflow.RegisterGuard("ok_kind", g)
t.Cleanup(func() { workflow.UnregisterGuardForTest("ok_kind") })
require.True(t, workflow.HasGuard("ok_kind"))
require.Panics(t, func() { workflow.RegisterGuard("ok_kind", g) })
}

If workflowtest lacks TryTransition, State, DecisionCount, SourceOf, add them there (small SQL reads; org_id = $1 predicates).

  • Step 2: Run — expect failure (undefined: workflow.RegisterGuard).

  • Step 3: Write guard.go and the call.

package workflow
// Move is what a Guard sees: the kind, the instance and its source row, who
// is moving it, and both states' specs, so a guard decides by semantic.
type Move struct {
Kind string
InstanceID, SourceID uuid.UUID
Actor uuid.UUID
From, To StateSpec
Reason string
}
// Guard may refuse a move, and may read and write rows in tx (e-Invoicing
// queues a submission in the issuing transaction). It runs after
// authorization, after a sign-off step completes, and before the instance
// moves or anything posts. It must NOT call audit.Write: the Poster's
// advisory lock is taken after it (see Transition's caller constraint).
type Guard func(ctx context.Context, tx pgx.Tx, c clock.Clock, m Move) error
var (
guardsMu sync.RWMutex
guards = map[string]Guard{}
)
// RegisterGuard installs kind's guard, from a platform package's init().
// One guard per kind: a second is a wiring bug.
func RegisterGuard(kind string, g Guard) {
if !KeyPattern.MatchString(kind) {
panic(fmt.Sprintf("workflow: RegisterGuard: kind %q must match %s", kind, KeyPattern))
}
if g == nil {
panic(fmt.Sprintf("workflow: RegisterGuard(%q): nil guard", kind))
}
guardsMu.Lock()
defer guardsMu.Unlock()
if _, dup := guards[kind]; dup {
panic(fmt.Sprintf("workflow: RegisterGuard(%q) called twice", kind))
}
guards[kind] = g
}
func HasGuard(kind string) bool { guardsMu.RLock(); defer guardsMu.RUnlock(); return guards[kind] != nil }
// UnregisterGuardForTest removes a test's guard. Production never calls it.
func UnregisterGuardForTest(kind string) { guardsMu.Lock(); defer guardsMu.Unlock(); delete(guards, kind) }
func guardFor(kind string) Guard { guardsMu.RLock(); defer guardsMu.RUnlock(); return guards[kind] }

In instance.go Transition, immediately after the if auth.Signs != nil { … } block and before the pastAccrual, pastPayout := line:

if g := guardFor(def.Kind); g != nil {
if err := g(ctx, tx, c, Move{Kind: def.Kind, InstanceID: inst.ID, SourceID: inst.SourceID,
Actor: in.Actor, From: from, To: to, Reason: reason}); err != nil {
return Decision{}, err
}
}

Add one sentence to Transition’s doc comment: “A kind’s Guard (RegisterGuard) runs here and may refuse; AvailableTransitions does not consult it.”

  • Step 4: einvoice/doc.go and registry.go. Pure types, no DB:
// Package einvoice submits issued documents to a tax authority's e-Invoice
// system and tracks them: the country-neutral half (spec D1). The
// authority's own rules, codes, encoding and HTTP client live in the
// country pack and meet this package at Codec and Client.
package einvoice
const (
StatusQueued = "queued"; StatusSubmitting = "submitting"; StatusSubmitted = "submitted"
StatusValid = "valid"; StatusInvalid = "invalid"; StatusCancelled = "cancelled"; StatusFailed = "failed"
TypeInvoice = "01"; TypeCreditNote = "02"; TypeSelfBilledInvoice = "11"; TypeSelfBilledCreditNote = "12"
FailWindowExpired = "window_expired"; FailRejectedStructure = "rejected_structure"; FailForbidden = "forbidden"
FailTooLarge = "too_large"; FailDuplicateUnresolved = "duplicate_unresolved"; FailNotConfigured = "not_configured"
)
// Party is a supplier or a buyer as the document declares it.
type Party struct {
Name, TIN, RegistrationType, RegistrationNo, SSTNo, MSIC, BusinessActivity string
Phone, Email string
AddressLine1, AddressLine2, City, Postcode, State, CountryCode string // State: free text or a code; the Codec maps it
}
type Line struct {
Description, ClassificationCode, UnitCode string
Quantity, UnitPrice money.Rate // AP lines: 1 and the net
Discount, Net, Tax money.Amount // sen
TaxPackKind string // the pack's own classification (sales_tax, service_tax, exempt, out_of_scope)
TaxRate money.Rate
TaxExemptionReason string
}
// Reference is a credit note's original: number, and the UUID ("" when the
// original was never e-Invoiced; the Codec decides what that encodes to).
type Reference struct{ CodeNumber, UUID string }
type Document struct {
Type string // Type*
CodeNumber string
IssuedAt time.Time // UTC
Currency string
FXRate money.Rate // to MYR; 1 at base
Supplier Party // the issuer for 01/02, the counterparty for 11/12
Buyer Party
Lines []Line
Original *Reference
}
// FieldError is one refusal; CheckError all of a document's at once.
type FieldError struct{ Field, Rule, Detail string }
func (e *FieldError) Error() string { return fmt.Sprintf("field=%s rule=%s: %s", e.Field, e.Rule, e.Detail) }
type CheckError struct{ Problems []FieldError }
func (e *CheckError) Error() string { /* joined with "; ", AP SubmissionError's shape */ }
// ConflictError is a 409 with a stable code (spec D10, D12).
type ConflictError struct{ Code, Detail string }
func (e *ConflictError) Error() string { return e.Code + ": " + e.Detail }
// Codec is the pure half of a country's authority: it checks a document
// against the authority's rules and encodes it, with no configuration.
type Codec interface {
Check(d Document) []FieldError
Encode(d Document) ([]byte, error) // compact, unsigned
}
// Taxpayer is who a call acts for.
type Taxpayer struct{ TIN, RegistrationNo string }
type Outgoing struct {
ID uuid.UUID // our row
CodeNumber string
Document []byte // exactly what is sent; signed when Signed
}
type Accepted struct{ CodeNumber, UUID string }
type Rejected struct {
CodeNumber string
Error json.RawMessage
}
type SubmitResult struct {
SubmissionUID string
Accepted []Accepted
Rejected []Rejected
}
// DocStatus is one document as the authority reports it.
type DocStatus struct {
UUID, SubmissionUID, LongID, InternalID string
Status string // the authority's word, normalized by the Client to Status*
ValidatedAt, CancelledAt, RejectRequestedAt *time.Time
Reason string
Errors json.RawMessage
}
// Client is the network half. Sign turns an encoded document into the bytes
// to send (and says which version it is); a Client with no certificate
// returns the document unchanged as version "1.0".
type Client interface {
Sign(doc []byte, at time.Time) (signed []byte, version string, err error)
Submit(ctx context.Context, t Taxpayer, docs []Outgoing) (SubmitResult, error)
Submission(ctx context.Context, t Taxpayer, uid string) ([]DocStatus, error)
Details(ctx context.Context, t Taxpayer, uuid string) (DocStatus, error)
Recent(ctx context.Context, t Taxpayer, from, to time.Time) ([]DocStatus, error)
Cancel(ctx context.Context, t Taxpayer, uuid, reason string) error
ValidationURL(uuid, longID string) string
Hash(doc []byte) string
}
// TransientError: try again later (RetryAfter when the authority said so).
type TransientError struct{ Err error; RetryAfter time.Duration }
// PermanentError: stop; Code is a Fail* constant.
type PermanentError struct{ Code string; Err error }
// UnknownOutcomeError: the request may have been accepted (spec D9).
type UnknownOutcomeError struct{ Err error }
// ConflictAtAuthority: cancel refused with the authority's code
// (window_closed, has_credit_notes, incorrect_state).
type AuthorityRefusal struct{ Code string; Err error }
var ErrNotConfigured = errors.New("einvoice: no e-Invoice client is configured in this process")

registry.go: RegisterCodec(country string, c Codec) (panics on duplicate), CodecFor(country) (Codec, bool), HasCodec, UseClient(c Client) / CurrentClient() (Client, error) (ErrNotConfigured when nil), each behind a sync.RWMutex, plus useForTest hooks returning a restore func (used by einvoicetest). registry_test.go: duplicate codec panics; CurrentClient before UseClient is ErrNotConfigured.

  • Step 5: Run — expect PASS: go test ./internal/platform/workflow/... ./internal/platform/einvoice/... -count=1.

  • Step 6: Sabotage. Move the guard call below the UPDATE workflow_instance: TestAGuardRefusalAbortsTheMove still green (the tx rolls back) — so also move it above the sign-off block: TestAPartialSignoffDoesNotRunTheGuard red (1 != 0 calls). Record, restore. Delete the call: TestAGuardRefusalAbortsTheMove red (err nil). Restore.

  • Step 7: Commit. MyInvois Task 1: workflow transition guard; neutral e-Invoice types


Task 2: LHDN codes, checks and the UBL 2.1 JSON encoder

Section titled “Task 2: LHDN codes, checks and the UBL 2.1 JSON encoder”

Files:

  • Create: internal/platform/countrypack/my/myinvois/{doc.go,codes.go,check.go,ubl.go,number.go}
  • Create: internal/platform/countrypack/my/myinvois/testdata/{invoice.json,credit_note.json,self_billed_invoice.json,foreign_invoice.json}
  • Test: internal/platform/countrypack/my/myinvois/{codes_test.go,check_test.go,ubl_test.go,nofloat_test.go}

Interfaces:

  • Consumes: einvoice.Document, Line, Party, FieldError, Codec.

  • Produces: myinvois.Codec (value type, implements einvoice.Codec), myinvois.StateCode(name string) (string, bool), myinvois.Alpha3(alpha2 string) (string, bool), myinvois.TaxType(packKind string) (string, bool), myinvois.ClassificationKnown(code string) bool, myinvois.GeneralTINForeignSupplier = "EI00000000030" (and the other three).

  • Step 1: Failing code-table tests. codes_test.go:

func TestStateNamesMapToLHDNCodes(t *testing.T) {
for in, want := range map[string]string{"Johor": "01", "pulau pinang": "07", "Penang": "07", " Kuala Lumpur ": "14",
"Wilayah Persekutuan Kuala Lumpur": "14", "07": "07", "Negri Sembilan": "05", "Labuan": "15", "Putrajaya": "16"} {
got, ok := myinvois.StateCode(in)
require.True(t, ok, in)
require.Equal(t, want, got, in)
}
_, ok := myinvois.StateCode("Atlantis")
require.False(t, ok)
}
func TestTaxTypesComeFromThePacksKinds(t *testing.T) { /* sales_tax→01 service_tax→02 exempt→E out_of_scope→06; "vat" unknown */ }
func TestClassificationCodesAreTheFortyFive(t *testing.T) { /* 001..045 known; 000, 046, "22" not */ }
func TestAlpha3(t *testing.T) { /* MY→MYS SG→SGP CN→CHN US→USA; "XX" unknown; the table has ≥ 240 entries */ }
  • Step 2: Run — expect failure.

  • Step 3: codes.go. Literal Go maps, no file loading: the 17 state codes with the SDK’s names plus M15’s aliases (normalize: strings.ToLower, collapse spaces); the 45 classification codes (code → SDK description, used by the web select through the API in Task 8); tax types (sales_tax→01, service_tax→02, exempt→E, out_of_scope→06); ISO 3166-1 alpha-2 → alpha-3 for every officially assigned code (249). Header comment: “Codes read from sdk.myinvois.hasil.gov.my/codes on 2026-09-27; re-verify at go-live (DEPLOY_NOTES).”

  • Step 4: Failing check tests. check_test.go builds a complete einvoice.Document (helper complete()) and asserts:

func TestACompleteInvoicePassesAndEachGapIsNamed(t *testing.T) {
require.Empty(t, myinvois.Codec{}.Check(complete())) // presence
for field, mutate := range map[string]func(*einvoice.Document){
"buyer.tin": func(d *einvoice.Document) { d.Buyer.TIN = "" },
"buyer.registration_no": func(d *einvoice.Document) { d.Buyer.RegistrationNo = "" },
"buyer.address_line1": func(d *einvoice.Document) { d.Buyer.AddressLine1 = "" },
"buyer.state": func(d *einvoice.Document) { d.Buyer.State = "Atlantis" },
"buyer.phone": func(d *einvoice.Document) { d.Buyer.Phone = "" },
"supplier.msic": func(d *einvoice.Document) { d.Supplier.MSIC = "" },
"supplier.business_activity": func(d *einvoice.Document) { d.Supplier.BusinessActivity = "" },
"lines[0].classification_code": func(d *einvoice.Document) { d.Lines[0].ClassificationCode = "099" },
"lines[0].tax_exemption_reason": func(d *einvoice.Document) { d.Lines[0].TaxPackKind = "exempt"; d.Lines[0].TaxExemptionReason = "" },
"lines[0].tax_type": func(d *einvoice.Document) { d.Lines[0].TaxPackKind = "vat" },
"original": func(d *einvoice.Document) { d.Type = einvoice.TypeCreditNote; d.Original = nil },
} {
d := complete()
mutate(&d)
require.Contains(t, fields(myinvois.Codec{}.Check(d)), field, field)
}
}
// A foreign buyer: state 17 without a state, no postcode needed; the general
// foreign-buyer TIN is accepted.
func TestAForeignBuyerNeedsNoMalaysianState(t *testing.T) { … }
  • Step 5: check.go — spec D5’s list, D13’s for types 11/12 (the supplier is the counterparty: a foreign supplier may carry EI00000000030, NA registration; the buyer is the organization). Rules codes: required, tin_shape (the taxrecord regex, copied as a literal with a comment naming taxrecord.supplierTIN), state_unknown (detail lists “a Malaysian state name or its two-digit code”), country_unknown, classification_unknown, tax_type_unknown, exemption_reason_required, unit_code_shape (^[A-Z0-9]{1,3}$), original_required. Field names as in the test (buyer.*, supplier.*, lines[i].*).

  • Step 6: Failing encoder tests. ubl_test.go:

// Golden files are our reading of the SDK's published samples (spec D14):
// matching them proves we produce what we believe LHDN wants; the sandbox
// checklist proves LHDN agrees. Regenerate with -update only after reading
// the diff.
func TestTheEncoderMatchesTheGoldenFiles(t *testing.T) {
for name, d := range map[string]einvoice.Document{"invoice": complete(), "credit_note": creditNote(),
"self_billed_invoice": selfBilled(), "foreign_invoice": foreignCurrency()} {
got, err := myinvois.Codec{}.Encode(d)
require.NoError(t, err)
golden(t, "testdata/"+name+".json", got)
}
}
// Amounts are decimal text on the wire, never a float's rendering.
func TestAmountsAreWrittenFromTheirDecimalText(t *testing.T) {
d := complete()
d.Lines[0].Net = money.MustParse("1234567890123.10")
got, _ := myinvois.Codec{}.Encode(d)
require.Contains(t, string(got), `"LineExtensionAmount":[{"_":1234567890123.10,"currencyID":"MYR"}]`)
}
func TestTheEncodingIsCompactAndHasNoSignature(t *testing.T) { /* no '\n', no "UBLExtensions", no "Signature"; InvoiceTypeCode listVersionID "1.0" */ }
  • Step 7: number.go and ubl.go.
number.go
// num writes a decimal as a JSON number from its text (spec D14): LHDN's
// JSON wants numbers, and the value must never pass through float64.
func num(a money.Amount) json.Number { return json.Number(a.StringFixed(2)) }
func rate(r money.Rate) json.Number { return json.Number(r.String()) }

(If money.Amount has no StringFixed, add it in internal/kernel/money with a test — decimal.StringFixed underneath. Check first: grep -n "func (a Amount)" internal/kernel/money/*.go.)

ubl.go: small typed builders rather than a struct per UBL element — UBL JSON wraps every value as [{"_": v, attr: …}]:

type obj = map[string]any
func v(x any, attrs ...string) []obj { o := obj{"_": x}; for i := 0; i+1 < len(attrs); i += 2 { o[attrs[i]] = attrs[i+1] }; return []obj{o} }
func group(fields obj) []obj { return []obj{fields} }
func (Codec) Encode(d einvoice.Document) ([]byte, error) {
cur := d.Currency
inv := obj{
"ID": v(d.CodeNumber),
"IssueDate": v(d.IssuedAt.UTC().Format("2006-01-02")),
"IssueTime": v(d.IssuedAt.UTC().Format("15:04:05Z")),
"InvoiceTypeCode": v(d.Type, "listVersionID", "1.0"),
"DocumentCurrencyCode": v(cur),
"TaxCurrencyCode": v("MYR"),
"AccountingSupplierParty": group(obj{"Party": party(d.Supplier)}),
"AccountingCustomerParty": group(obj{"Party": party(d.Buyer)}),
"InvoiceLine": lines(d),
"TaxTotal": taxTotal(d),
"LegalMonetaryTotal": totals(d),
}
if cur != "MYR" {
inv["TaxExchangeRate"] = group(obj{"SourceCurrencyCode": v(cur), "TargetCurrencyCode": v("MYR"), "CalculationRate": v(rate(d.FXRate))})
}
if d.Original != nil {
uuid := d.Original.UUID
if uuid == "" {
uuid = "NA" // spec D12: a pre-mandate original [unverified]
}
inv["BillingReference"] = group(obj{"InvoiceDocumentReference": group(obj{"ID": v(d.Original.CodeNumber), "UUID": v(uuid)})})
}
return json.Marshal(obj{
"_D": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
"_A": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
"_B": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
"Invoice": []obj{inv},
})
}

encoding/json sorts map keys, so the output is deterministic — the golden files depend on it. party: PartyIdentification (TIN, then the registration type’s scheme BRN|NRIC|PASSPORT|ARMY, SST (NA when empty), TTX NA), PostalAddress (CityName, PostalZone, CountrySubentityCode via StateCode / 17, AddressLine ×1–2 {"Line": v(...)}, Country.IdentificationCode alpha-3 with listID "ISO3166-1", listAgencyID "6"), PartyLegalEntity.RegistrationName, Contact (Telephone, ElectronicMail when set), and for the supplier IndustryClassificationCode (MSIC with name = business activity). Lines: ID (1-based), InvoicedQuantity (unitCode when set), LineExtensionAmount (net), AllowanceCharge when discount > 0 (ChargeIndicator false, Amount), TaxTotal (TaxAmount, one TaxSubtotal: TaxableAmount, TaxAmount, Percent, TaxCategory {ID: tax type, TaxExemptionReason when set, TaxScheme {ID: v("OTH", "schemeID", "UN/ECE 5153", "schemeAgencyID", "6")}}), Item (CommodityClassification {ItemClassificationCode: v(code, "listID", "CLASS")}, Description), Price.PriceAmount, ItemPriceExtension.Amount (net + discount). Document TaxTotal: Σ tax, one subtotal per tax type. LegalMonetaryTotal: LineExtensionAmount Σ net, TaxExclusiveAmount Σ net, TaxInclusiveAmount Σ net + tax, PayableAmount the same. Every amount carries currencyID = document currency.

Write the four golden files by hand from the SDK’s JSON sample for the corresponding type, then make the encoder match them (not the reverse). Where the sample and spec D14 disagree, follow the sample and note it in the golden file’s sibling testdata/README.md (one line per difference).

  • Step 8: nofloat_test.go — copy internal/platform/reporting/render/nofloat_test.go’s scan, pointed at this package (no exemptions). Presence: the scan reads ≥ 4 files.

  • Step 9: Run — expect PASS: go test ./internal/platform/countrypack/my/myinvois/... -count=1.

  • Step 10: Sabotage. (a) Make num return json.Number(strconv.FormatFloat(a.Float64(), 'f', -1, 64)) — if Amount has no Float64, write float64 anywhere in number.go: nofloat_test red naming number.go. (b) Drop the exempt reason rule: TestACompleteInvoicePassesAndEachGapIsNamed red on lines[0].tax_exemption_reason. Restore both; record the messages.

  • Step 11: Commit. MyInvois Task 2: LHDN code tables, in-scope checks, UBL 2.1 JSON encoder


Task 3: Signer, HTTP client, configuration and the fake server

Section titled “Task 3: Signer, HTTP client, configuration and the fake server”

Files:

  • Create: internal/platform/countrypack/my/myinvois/{sign.go,client.go,errors.go}
  • Create: internal/platform/countrypack/my/myinvois/myinvoisfake/fake.go
  • Create: cmd/myinvois-fake/main.go
  • Create: internal/kernel/config/myinvois.go
  • Modify: .env.example
  • Test: internal/platform/countrypack/my/myinvois/{sign_test.go,client_test.go,sandbox_test.go}, internal/platform/countrypack/my/myinvois/myinvoisfake/fake_test.go, internal/kernel/config/myinvois_test.go

Interfaces:

  • Consumes: einvoice.Client, Taxpayer, Outgoing, SubmitResult, DocStatus, the error types, clock.Clock.

  • Produces: config.MyInvois{APIURL, IdentityURL, PortalURL, ClientID, ClientSecret, CertFile, KeyFile string; Loopback bool}, config.MyInvoisFromEnv() (config.MyInvois, error) (zero value = disabled); myinvois.Config, myinvois.NewClient(cfg myinvois.Config, c clock.Clock) (*myinvois.Client, error), myinvois.LoadSigner(certFile, keyFile string) (*myinvois.Signer, error); myinvoisfake.New(c clock.Clock) *myinvoisfake.Server with Script(codeNumber string, s Script), Calls() []Call, ServeHTTP; the cmd/myinvois-fake binary (-addr).

  • Step 1: Failing config tests. myinvois_test.go (mirror bnm_test.go):

func TestMyInvoisIsDisabledWhenUnset(t *testing.T) { clear(t); cfg, err := config.MyInvoisFromEnv(); require.NoError(t, err); require.Empty(t, cfg.APIURL) }
func TestMyInvoisHostsMustBeLHDNOrLoopback(t *testing.T) {
set(t, valid()) // https://preprod-api.myinvois.hasil.gov.my etc.
_, err := config.MyInvoisFromEnv()
require.NoError(t, err) // presence
for _, bad := range []string{"http://preprod-api.myinvois.hasil.gov.my", "https://myinvois.example.com", "http://10.0.0.5:8080"} {
t.Setenv("MYINVOIS_API_URL", bad)
_, err := config.MyInvoisFromEnv()
require.Error(t, err, bad)
}
t.Setenv("MYINVOIS_API_URL", "http://127.0.0.1:3177")
cfg, err := config.MyInvoisFromEnv()
require.NoError(t, err)
require.True(t, cfg.Loopback)
}
func TestTheSecretPlaceholderIsRefused(t *testing.T) { /* MYINVOIS_CLIENT_SECRET=REPLACE_ME… refused, like OBJECT_STORE_SECRET_KEY */ }
func TestACertificateNeedsItsKey(t *testing.T) { /* cert set, key empty → error; both → ok; neither → ok */ }
  • Step 2: config/myinvois.go. Hosts: https and host hasil.gov.my or *.hasil.gov.my; or http with host 127.0.0.1/localhost and an explicit port (Loopback = true). API URL set ⇒ identity URL, portal URL, client ID and secret required. Secret placeholder refused (reuse the placeholder check in placeholders_test.go’s helper). Cert and key both or neither. Trailing / trimmed.

.env.example gains (text to use):

# LHDN MyInvois (spec 2026-09-27-myinvois-design.md). Empty MYINVOIS_API_URL
# disables e-Invoice submission: documents of in-scope organizations are
# queued and, after LHDN's 72-hour window, marked failed. Hosts must be https
# on hasil.gov.my — sandbox: https://preprod-api.myinvois.hasil.gov.my and
# portal https://preprod.myinvois.hasil.gov.my; production: api. / no prefix.
# A loopback http URL is accepted ONLY for the local fake (cmd/myinvois-fake)
# and logs a warning at boot: a production deployment pointed at it would
# "validate" invoices against nothing.
MYINVOIS_API_URL=
MYINVOIS_IDENTITY_URL=
MYINVOIS_PORTAL_URL=
# The service provider's INTERMEDIARY credentials (spec D2). Each customer
# organization authorizes this intermediary in the MyInvois portal. A
# placeholder secret is refused at boot.
MYINVOIS_CLIENT_ID=
MYINVOIS_CLIENT_SECRET=
# Worker only. The intermediary's signing certificate (PEM chain) and RSA
# private key (unencrypted PKCS#8 PEM), mounted as files readable only by the
# worker, never in the database or its backups (spec D3). Both empty: document
# version 1.0, unsigned. One without the other: the worker refuses to boot.
MYINVOIS_SIGNING_CERT_FILE=
MYINVOIS_SIGNING_KEY_FILE=
# Developer machine only: the opt-in sandbox smoke test
# (go test -tags myinvois_sandbox ./internal/platform/countrypack/my/myinvois/...).
# No binary reads these.
MYINVOIS_SANDBOX_CLIENT_ID=
MYINVOIS_SANDBOX_CLIENT_SECRET=
MYINVOIS_SANDBOX_TIN=
  • Step 3: Failing signer test. sign_test.go generates an RSA-2048 key and a self-signed certificate in the test (x509.CreateCertificate), writes them to t.TempDir(), encodes testdata/invoice.json (Task 2’s golden) and:
func TestASignedDocumentVerifies(t *testing.T) {
s := loadTestSigner(t)
signed, version, err := s.Sign(golden(t, "invoice"), t0)
require.NoError(t, err)
require.Equal(t, "1.1", version)
require.NoError(t, myinvoisfake.VerifySignature(signed)) // the fake's own verifier
tampered := bytes.Replace(signed, []byte("INV-2026-0001"), []byte("INV-2026-0002"), 1)
require.Error(t, myinvoisfake.VerifySignature(tampered)) // presence of the check
}
func TestNoSignerMeansVersionOneUnsigned(t *testing.T) { /* (*Signer)(nil).Sign returns the input and "1.0" */ }
  • Step 4: sign.go — the SDK’s seven JSON steps (spec’s LHDN table):
type Signer struct {
key *rsa.PrivateKey
cert *x509.Certificate
}
// Sign is the SDK's JSON procedure. doc is Codec.Encode's compact output,
// which has no UBLExtensions or Signature, so step 1 (remove them, minify)
// is already true of it; a test holds that.
func (s *Signer) Sign(doc []byte, at time.Time) ([]byte, string, error) {
if s == nil {
return doc, "1.0", nil
}
var root map[string]any
if err := json.Unmarshal(doc, &root); err != nil { return nil, "", err }
inv := root["Invoice"].([]any)[0].(map[string]any)
setVersion(inv, "1.1") // InvoiceTypeCode.listVersionID
body, err := json.Marshal(root) // step 1: the document as hashed
if err != nil { return nil, "", err }
docDigest := sha256.Sum256(body) // step 2
sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, docDigest[:]) // step 3
if err != nil { return nil, "", err }
certDigest := sha256.Sum256(s.cert.Raw) // step 4
props := signedProperties(at, certDigest[:], s.cert) // step 5: SigningTime, CertDigest, IssuerSerial
propsJSON, _ := json.Marshal(props)
propsDigest := sha256.Sum256(propsJSON) // step 6
inv["UBLExtensions"] = extensions(sig, docDigest[:], propsDigest[:], props, s.cert) // step 7
inv["Signature"] = []any{map[string]any{
"ID": v("urn:oasis:names:specification:ubl:signature:Invoice"),
"SignatureMethod": v("urn:oasis:names:specification:ubl:dsig:enveloped:xades"),
}}
out, err := json.Marshal(root)
return out, "1.1", err
}

extensions builds UBLExtension → ExtensionURI urn:oasis:names:specification:ubl:dsig:enveloped:xades → ExtensionContent → UBLDocumentSignatures → SignatureInformation (ID urn:oasis:names:specification:ubl:signature:1, ReferencedSignatureID urn:oasis:names:specification:ubl:signature:Invoice) → Signature (Id signature, SignedInfo with SignatureMethod rsa-sha256 and two References — the document (DigestValuebase64 ofdocDigest) and #id-xades-signed-props (propsDigest), SignatureValue base64(sig), KeyInfo.X509Data (certificate base64, subject, issuer serial), Object.QualifyingProperties.SignedProperties = props). Comment at the top of the function: “Shape transcribed from the SDK’s JSON signature page and sample, 2026-09-27; [unverified] until the sandbox checklist passes.”

LoadSigner(certFile, keyFile): pem.Decode both; x509.ParsePKCS8PrivateKey (refuse a non-RSA key); x509.ParseCertificate of the first CERTIFICATE block; refuse when the key does not match the certificate’s public key; returns (nil, nil) when both paths are empty.

  • Step 5: Failing fake tests. fake_test.go drives the fake with a raw http.Client (no myinvois.Client), one test per behaviour the spec’s D20 lists: token; submit accepted then Submission shows Valid with a longId; ScriptInvalid shows Invalid and details carry validationResults; ScriptRejectAtSubmit returns it in rejectedDocuments; ScriptFail(3, 500) fails three times then accepts; ScriptThrottle returns 429 with Retry-After: 7; ScriptDropResponse accepts then returns 502 (the document is in recent); a byte-identical resubmission within 10 minutes is 422 DuplicateSubmission, after 10 minutes accepted as a new uuid; cancel before 72 h after validation succeeds, after returns OperationPeriodOver; ScriptRejectRequest sets rejectRequestDateTime on details; a wrong documentHash is 400 BadStructure; a version-1.1 document with a bad signature is invalid with a signature validation step error; a request without onbehalfof or with a token for another TIN is 403.

  • Step 6: myinvoisfake/fake.go. One http.ServeMux, state in memory under a mutex, clock injected. Documents are keyed by uuid (26 upper-case alphanumerics, crypto/rand); long ids likewise. It checks documentHash == hex(sha256(decoded)) (the client’s Hash must agree — [unverified] hex vs base64; the fake enforces hex, matching the spec’s reading). It exports VerifySignature(doc []byte) error (the inverse of Sign: strip UBLExtensions/Signature, reset listVersionID to 1.1, re-marshal, SHA-256, verify with the embedded certificate’s public key). Calls() records every request (method, path, onbehalfof) so tests can assert “no cancel call was made”.

cmd/myinvois-fake/main.go: -addr flag (default 127.0.0.1:3177), clock.System(), http.ListenAndServe(addr, myinvoisfake.New(clock.System())), a log line “FAKE MyInvois — not LHDN”. Every document validates unless its code number ends in -INVALID (lets the e2e or a person try the invalid path).

  • Step 7: Failing client tests. client_test.go against httptest.NewServer(myinvoisfake.New(c)):
func TestTheClientClassifiesEveryOutcome(t *testing.T) {
// 500 → *einvoice.TransientError; 429 → TransientError{RetryAfter: 7s};
// 403 → *einvoice.PermanentError{Code: einvoice.FailForbidden};
// 400 BadStructure → PermanentError{FailRejectedStructure}; 422 DuplicateSubmission and a
// dropped response (502 after accept, or a connection reset) → *einvoice.UnknownOutcomeError;
// cancel OperationPeriodOver → *einvoice.AuthorityRefusal{Code: "window_closed"},
// ActiveReferencingDocuments → {Code: "has_credit_notes"}, IncorrectState → {Code: "incorrect_state"}.
}
func TestTokensAreCachedPerTaxpayerAndRefreshedOn401(t *testing.T) { /* two calls, one login; fake revokes → one re-login, call succeeds */ }
func TestAnIGTaxpayerActsAsTINColonBRN(t *testing.T) { /* Taxpayer{TIN: "IG12345678912", RegistrationNo: "201901234567"} → onbehalfof "IG12345678912:201901234567" */ }
func TestStatusesAreNormalized(t *testing.T) { /* "Valid"/"valid" → einvoice.StatusValid, "Submitted" → StatusSubmitted, … */ }
func TestTheValidationURLIsPortalUUIDShareLongID(t *testing.T) { … }
  • Step 8: client.go, errors.go. Client{cfg Config; http *http.Client (30 s); signer *Signer; clock; mu; tokens map[string]token}. Hash = lower-case hex SHA-256. Submit builds the body {"documents":[{"format":"JSON","document":base64,"documentHash":Hash,"codeNumber":…}]}. errors.go holds the one classify(resp *http.Response, body []byte, err error) error switch — the spec’s D9 table and the Step 7 list, nothing else decides retryability. NewClient returns an error when cfg.APIURL is empty (callers do not register a client then).

  • Step 9: Sandbox smoke test sandbox_test.go:

//go:build myinvois_sandbox
// Talks to LHDN's sandbox with MYINVOIS_SANDBOX_* — never in CI (spec D20).
// Submits one invoice, polls to a final status, cancels it, and prints the
// validation link for a person to open.
func TestSandboxRoundTrip(t *testing.T) { … }
  • Step 10: Run — expect PASS: go test ./internal/platform/countrypack/my/myinvois/... ./internal/kernel/config/... -count=1 (the env drift test passes because .env.example lists every variable; the sandbox file is excluded by its tag).

  • Step 11: Sabotage. (a) Remove MYINVOIS_SIGNING_KEY_FILE from .env.example: TestEveryEnvVarReadAppearsInEnvExample red naming it. (b) In classify, map 429 to PermanentError: the client test red at the 429 case. (c) In Sign, hash doc instead of the version-1.1 body: TestASignedDocumentVerifies red. Restore; record.

  • Step 12: Commit. MyInvois Task 3: signer, client, configuration, fake MyInvois server


Task 4: Settings, submissions and the sales-side guards

Section titled “Task 4: Settings, submissions and the sales-side guards”

Requires AR merged into phase-2.

Files:

  • Create: db/migrations/00380_einvoice.sql
  • Create: internal/platform/einvoice/{setting.go,submission.go,build.go,guard.go,cursor.go}
  • Create: internal/platform/einvoice/einvoicetest/env.go
  • Modify: internal/kernel/db/securitydefiner_test.go, internal/kernel/db/retention_test.go, sqlc.yaml (only if sqlc reads these tables — AR/AP use hand-written pgx; follow them)
  • Test: internal/platform/einvoice/{schema_test.go,setting_test.go,guard_test.go,dueorgs_test.go}

Interfaces:

  • Consumes: receivables.GetDocument, GetCustomer, KindInvoice, KindCreditNote, Document fields; company.Get, company.Update; workflow.RegisterGuard; myinvois.Codec (tests register it; production registers in Task 8).

  • Produces: tables; einvoice.Setting, SettingInput, GetSetting, UpdateSetting(ctx, tx, c, in, expectedRevision, by); Submission, GetSubmission, ForSales(ctx, tx, docID) (*Submission, error), ForPurchase, ListSubmissions(ctx, tx, Filter); DueOrgs(ctx, pool); the four-state void rule voidAllowed; einvoicetest.Env (on receivablestest.Env) with InScope(t), Fake, UseCodec(t), UseClient(t).

  • Step 1: Failing schema tests. schema_test.go: RLS by the drift test once the tables exist (run it); presence-then-absence for each named CHECK (einvoice_submission_one_source, _type_known, _status_known, _failed_has_code, _valid_has_long_id, _sales_once) and the freeze trigger (einvoice_submission_frozen: an UPDATE of document_json refused by name; of status accepted; submitted_document settable once, then refused).

  • Step 2: 00380_einvoice.sql.

-- +goose Up
-- e-Invoicing (spec 2026-09-27-myinvois-design.md D4, D6, D8).
CREATE TABLE einvoice_setting (
org_id UUID PRIMARY KEY REFERENCES organization (id),
in_scope BOOLEAN NOT NULL DEFAULT false,
in_scope_since TIMESTAMPTZ,
business_activity TEXT CONSTRAINT einvoice_setting_activity_length CHECK (business_activity IS NULL OR length(business_activity) <= 300),
phone TEXT CONSTRAINT einvoice_setting_phone_shape CHECK (phone IS NULL OR phone ~ '^\+?[0-9 -]{6,20}$'),
email TEXT CONSTRAINT einvoice_setting_email_shape CHECK (email IS NULL OR email ~ '^[^@[:space:]]+@[^@[:space:]]+$'),
address_line1 TEXT, address_line2 TEXT, address_city TEXT, address_postcode TEXT,
state_code TEXT CONSTRAINT einvoice_setting_state_code_shape CHECK (state_code IS NULL OR state_code ~ '^[0-9]{2}$'),
country_code TEXT CONSTRAINT einvoice_setting_country_code CHECK (country_code IS NULL OR country_code ~ '^[A-Z]{2}$'),
revision INT NOT NULL DEFAULT 1 CONSTRAINT einvoice_setting_revision_positive CHECK (revision >= 1),
updated_by UUID,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT einvoice_setting_scope_since CHECK (in_scope = (in_scope_since IS NOT NULL)),
CONSTRAINT einvoice_setting_text_language CHECK (
coalesce(business_activity, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'
AND coalesce(address_line1, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'
AND coalesce(address_line2, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'
AND coalesce(address_city, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT einvoice_setting_updated_by_fk FOREIGN KEY (org_id, updated_by) REFERENCES organization_member (org_id, id)
);
CREATE TABLE einvoice_submission (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
sales_document_id UUID,
purchase_document_id UUID,
document_type TEXT NOT NULL CONSTRAINT einvoice_submission_type_known CHECK (document_type IN ('01','02','11','12')),
code_number TEXT NOT NULL CONSTRAINT einvoice_submission_code_number_present
CHECK (btrim(code_number) <> '' AND length(code_number) <= 50),
issued_at TIMESTAMPTZ NOT NULL,
document_json JSONB NOT NULL,
document_version TEXT CONSTRAINT einvoice_submission_version_known CHECK (document_version IS NULL OR document_version IN ('1.0','1.1')),
submitted_document BYTEA,
document_hash TEXT CONSTRAINT einvoice_submission_hash_shape CHECK (document_hash IS NULL OR document_hash ~ '^[0-9a-f]{64}$'),
status TEXT NOT NULL CONSTRAINT einvoice_submission_status_known
CHECK (status IN ('queued','submitting','submitted','valid','invalid','cancelled','failed')),
failure_code TEXT CONSTRAINT einvoice_submission_failure_known CHECK (failure_code IS NULL OR failure_code IN
('window_expired','rejected_structure','forbidden','too_large','duplicate_unresolved','not_configured')),
submission_uid TEXT,
uuid TEXT,
long_id TEXT,
validation_url TEXT,
validated_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
reject_requested_at TIMESTAMPTZ,
-- Our cancellation reason (record language, checked in Go) or LHDN's text
-- (a buyer's rejection reason arrives in whatever script the buyer wrote):
-- no script CHECK here, so the worker can always record what LHDN said.
status_reason TEXT,
errors JSONB,
attempts INT NOT NULL DEFAULT 0 CONSTRAINT einvoice_submission_attempts_nonnegative CHECK (attempts >= 0),
first_sent_at TIMESTAMPTZ,
next_check_at TIMESTAMPTZ,
revision INT NOT NULL DEFAULT 1 CONSTRAINT einvoice_submission_revision_positive CHECK (revision >= 1),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT einvoice_submission_tenant_id UNIQUE (org_id, id),
CONSTRAINT einvoice_submission_one_source CHECK (num_nonnulls(sales_document_id, purchase_document_id) = 1),
CONSTRAINT einvoice_submission_sales_once UNIQUE (org_id, sales_document_id),
CONSTRAINT einvoice_submission_purchase_once UNIQUE (org_id, purchase_document_id),
CONSTRAINT einvoice_submission_type_matches_source CHECK (
(sales_document_id IS NOT NULL) = (document_type IN ('01','02'))),
CONSTRAINT einvoice_submission_failed_has_code CHECK ((status = 'failed') = (failure_code IS NOT NULL)),
CONSTRAINT einvoice_submission_sent_has_bytes CHECK (status IN ('queued','invalid','failed') OR submitted_document IS NOT NULL),
CONSTRAINT einvoice_submission_accepted_has_uuid CHECK (status NOT IN ('submitted','valid','cancelled') OR uuid IS NOT NULL),
CONSTRAINT einvoice_submission_valid_has_long_id CHECK (status <> 'valid'
OR (long_id IS NOT NULL AND validated_at IS NOT NULL AND validation_url IS NOT NULL)),
CONSTRAINT einvoice_submission_sales_fk FOREIGN KEY (org_id, sales_document_id) REFERENCES sales_invoice (org_id, id),
CONSTRAINT einvoice_submission_purchase_fk FOREIGN KEY (org_id, purchase_document_id) REFERENCES purchase_invoice (org_id, id)
);
CREATE UNIQUE INDEX einvoice_submission_uuid_once ON einvoice_submission (org_id, uuid) WHERE uuid IS NOT NULL;
CREATE INDEX einvoice_submission_due ON einvoice_submission (next_check_at) WHERE next_check_at IS NOT NULL;
CREATE INDEX einvoice_submission_by_created ON einvoice_submission (org_id, created_at DESC, id DESC);
-- What was declared never changes (D6): the source, the type, the number,
-- the unsigned document; the sent bytes and their hash are written once.
-- +goose StatementBegin
CREATE FUNCTION einvoice_submission_frozen() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF NEW.sales_document_id IS DISTINCT FROM OLD.sales_document_id
OR NEW.purchase_document_id IS DISTINCT FROM OLD.purchase_document_id
OR NEW.document_type IS DISTINCT FROM OLD.document_type
OR NEW.code_number IS DISTINCT FROM OLD.code_number
OR NEW.issued_at IS DISTINCT FROM OLD.issued_at
OR NEW.document_json IS DISTINCT FROM OLD.document_json
OR (OLD.submitted_document IS NOT NULL AND NEW.submitted_document IS DISTINCT FROM OLD.submitted_document)
OR (OLD.document_hash IS NOT NULL AND NEW.document_hash IS DISTINCT FROM OLD.document_hash) THEN
RAISE EXCEPTION 'e-Invoice submission % is frozen', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'einvoice_submission_frozen';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER einvoice_submission_frozen BEFORE UPDATE ON einvoice_submission
FOR EACH ROW EXECUTE FUNCTION einvoice_submission_frozen();
ALTER TABLE einvoice_setting ENABLE ROW LEVEL SECURITY;
ALTER TABLE einvoice_setting FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON einvoice_setting
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE einvoice_submission ENABLE ROW LEVEL SECURITY;
ALTER TABLE einvoice_submission FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON einvoice_submission
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON einvoice_setting, einvoice_submission FROM erp_app;
-- The worker's sweep has no organization, and forced RLS hides every row
-- from it (D8). This returns the ids of organizations with work due and
-- nothing else. It runs as its owner, the migration role, which must bypass
-- RLS; it refuses otherwise rather than silently returning nothing.
-- +goose StatementBegin
CREATE FUNCTION einvoice_due_orgs() RETURNS SETOF uuid
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF NOT (SELECT rolsuper OR rolbypassrls FROM pg_catalog.pg_roles WHERE rolname = current_user) THEN
RAISE EXCEPTION 'einvoice_due_orgs: owner % cannot bypass row-level security', current_user;
END IF;
RETURN QUERY SELECT DISTINCT s.org_id FROM public.einvoice_submission s WHERE s.next_check_at <= now();
END $$;
-- +goose StatementEnd
REVOKE ALL ON FUNCTION einvoice_due_orgs() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION einvoice_due_orgs() TO erp_app;
-- +goose Down
DROP FUNCTION einvoice_due_orgs();
DROP TABLE einvoice_submission;
DROP FUNCTION einvoice_submission_frozen();
DROP TABLE einvoice_setting;

now() inside the function is the database clock: the worker’s tests set next_check_at relative to now() (not the test clock) for this one query, and the per-org claim query below compares against the injected clock — note it in a comment.

securitydefiner_test.go: "einvoice_due_orgs": "e-Invoice sweep: organization ids with work due, no row content (spec 2026-09-27-myinvois-design.md D8)". retention_test.go: both tables amendable.

  • Step 3: Failing due-orgs test (dueorgs_test.go):
// PRESENCE: a due row's org is returned. ABSENCE: a not-yet-due row's org
// and a finished row's org are not; nothing but ids comes back.
func TestDueOrgsNamesOnlyOrganizationsWithWorkDue(t *testing.T) { … e.Another(t) for the second org … }
  • Step 4: setting.go. GetSetting returns the zero Setting (out of scope) when no row; UpdateSetting upserts with revision optimistic check (RevisionMismatchError, AR’s shape), writes the four company fields through company.Get → modify → company.Update, and when in_scope turns on runs the D4 list against the would-be values plus CodecFor(profile.CountryCode) present — refusing with *CheckError. in_scope_since set on the off→on edge, cleared on on→off. audit.Write last (entity einvoice_setting, before/after including the company fields).

setting_test.go: turning on with a gap is refused per field (presence: the complete input turns it on and sets in_scope_since); off clears it; a non-MY company is refused country_unsupported; the company TIN is written to company_profile.

  • Step 5: Failing guard tests (guard_test.go, on einvoicetest.Env, Poster in use):
func TestAnInScopeInvoiceIsCheckedAndQueuedInTheIssuingTransaction(t *testing.T) {
e := einvoicetest.New(t)
e.InScope(t)
inv := e.Invoice(t, e.CompleteCustomer(t), receivablestest.Line(e.Revenue, e.ST10, "1", "100.00"))
e.Issue(t, inv)
s, err := e.SubmissionFor(t, inv)
require.NoError(t, err)
require.Equal(t, einvoice.StatusQueued, s.Status)
require.Equal(t, einvoice.TypeInvoice, s.DocumentType)
require.Equal(t, "INV-2026-0001", s.CodeNumber)
require.JSONEq(t, string(e.Encode(t, inv)), string(s.DocumentJSON)) // built from the frozen snapshot
}
func TestAnIncompleteBuyerIsRefusedInScopeAndIssuesOutOfScope(t *testing.T) {
e := einvoicetest.New(t)
bare := e.Customer(t, "BARE") // no TIN, no address
inv := e.Invoice(t, bare, receivablestest.Line(e.Revenue, e.ST10, "1", "100.00"))
e.Issue(t, inv) // presence: out of scope it issues
require.Nil(t, e.SubmissionOrNil(t, inv))
e.InScope(t)
inv2 := e.Invoice(t, bare, receivablestest.Line(e.Revenue, e.ST10, "1", "100.00"))
err := e.TryIssue(t, inv2)
var ce *einvoice.CheckError
require.ErrorAs(t, err, &ce)
require.Contains(t, fields(ce), "buyer.tin")
require.Equal(t, "draft", e.State(t, inv2)) // nothing moved, no number consumed
}
// D4: switching scope on queues nothing issued before.
func TestScopeIsNotRetroactive(t *testing.T) { … }
// D7: after a mixed sequence, every issued document of an in-scope org has
// exactly one row, and no out-of-scope one has any.
func TestEveryIssuedInScopeDocumentHasOneSubmission(t *testing.T) { … }
// D12.
func TestACreditNoteWaitsForItsOriginalToBeValid(t *testing.T) {
/* original queued → credit note issue 409 original_einvoice_pending (presence: after SetStatus(valid) it issues,
its document_json carries BillingReference with the original's uuid) ; original invalid → 409 original_einvoice_not_valid;
original issued before scope → issues, reference UUID "NA" */
}
// D10's sales column (the void edge exists once Task 5 lands; until then
// these call voidAllowed directly — Task 5 re-runs them through the edge).
func TestTheSalesVoidRule(t *testing.T) { /* none → correct_by_credit_note; queued/submitting/submitted → einvoice_in_progress;
valid → einvoice_cancel_first; cancelled/invalid/failed → nil */ }

einvoicetest.New wraps receivablestest.New, registers myinvois.Codec{} for MY with a cleanup, creates the einvoice_setting row helper InScope(t) (complete seller fields, company TIN C20880050010, BRN, MSIC 62010), CompleteCustomer(t) (TIN, BRN, address in “Selangor”, phone), and starts a fake (httptest.NewServer(myinvoisfake.New(e.Clock))) with UseClient(t) registering a myinvois.Client pointed at it.

  • Step 6: build.go, submission.go, guard.go.
guard.go
func init() {
for _, k := range []string{receivables.KindInvoice, receivables.KindCreditNote} {
workflow.RegisterGuard(k, salesGuard)
}
}
// salesGuard is spec D5, D7, D10, D12 for AR's two kinds.
func salesGuard(ctx context.Context, tx pgx.Tx, c clock.Clock, m workflow.Move) error {
switch {
case m.From.Semantic == posting.Accrual && m.To.Semantic == posting.Cancelled:
s, err := ForSales(ctx, tx, m.SourceID)
if err != nil {
return err
}
return voidAllowed(s, true)
case m.To.Semantic == posting.Cancelled || m.To.Semantic == posting.Rejected:
return nil // withdraw / reject a draft
}
set, err := GetSetting(ctx, tx)
if err != nil || !set.InScope {
return err
}
d, err := salesDocument(ctx, tx, m.SourceID, set) // M6
if err != nil {
return err
}
codec, ok := CodecFor(countryOf(ctx, tx))
if !ok {
return &ConflictError{Code: "country_unsupported", Detail: "e-Invoicing is not available for this organization's country"}
}
if probs := codec.Check(d); len(probs) > 0 {
return &CheckError{Problems: probs}
}
if m.To.Semantic != posting.Accrual {
return nil // a credit note's submit: checked, not queued
}
if err := originalReady(ctx, tx, &d); err != nil { // D12, fills d.Original.UUID
return err
}
body, err := codec.Encode(d)
if err != nil {
return err
}
return queue(ctx, tx, c, queued{SalesDocumentID: &m.SourceID, Type: d.Type, CodeNumber: d.CodeNumber, IssuedAt: d.IssuedAt, JSON: body})
}
// voidAllowed is D10's table. sales: whether a document with no row may be
// voided at all (AR's "correct by credit note" rule).
func voidAllowed(s *Submission, sales bool) error {
switch {
case s == nil && sales:
return &ConflictError{Code: "correct_by_credit_note", Detail: "an issued document that was not e-Invoiced is corrected by a credit note"}
case s == nil:
return nil
case s.Status == StatusQueued, s.Status == StatusSubmitting, s.Status == StatusSubmitted:
return &ConflictError{Code: "einvoice_in_progress", Detail: "the e-Invoice is still being validated; try again in a minute"}
case s.Status == StatusValid:
return &ConflictError{Code: "einvoice_cancel_first", Detail: "cancel the e-Invoice first (within 72 hours of validation); after that, issue a credit note"}
}
return nil // cancelled, invalid, failed
}

queue inserts the row (status queued, next_check_at = c.Now(), id uuid.New(), org_id), no audit (Global Constraints: the guard never audits). salesDocument maps receivables.Document → einvoice.Document per M6 (issued: the snapshot columns; Supplier = seller snapshot + setting’s activity/phone/email/address; Buyer = buyer snapshot; lines: Quantity, UnitPrice, Discount, Net, Tax, TaxPackKind, TaxRate, ClassificationCode, UnitCode, TaxExemptionReason; Type 01/02; IssuedAt = issued_at). For a credit note’s submit (not yet issued) the buyer comes from receivables.GetCustomer, the seller from company.Get, CodeNumber a placeholder "DRAFT" (Check does not look at it).

submission.go: Submission struct (all columns), scanSubmission, ForSales, ForPurchase, GetSubmission, ListSubmissions (keyset M14; each row also carries source_kind and the counterparty name by a LEFT JOIN to sales_invoice/purchase_invoice+party — org_id on every join), and the internal setStatus(ctx, tx, c, id, change) used by Tasks 6 and 8 (one UPDATE with revision = revision + 1, then audit.Write of before/after status).

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

  • Step 8: Sabotage. (a) Return nil from salesGuard before queue: TestEveryIssuedInScopeDocumentHasOneSubmission red. (b) Comment out the RAISE in einvoice_submission_frozen: schema_test red at the document_json UPDATE. (c) Remove einvoice_due_orgs from securityDefiners: TestEverySecurityDefinerIsAllowlistedAndNotPublic red naming it. Restore each; record.

  • Step 9: Commit. MyInvois Task 4: e-Invoice settings, submissions, sales-side guards


Task 5: AR’s graphs v2, the void’s allocation rule, provisioning step 78

Section titled “Task 5: AR’s graphs v2, the void’s allocation rule, provisioning step 78”

Files:

  • Modify: internal/platform/receivables/graphs.go, internal/platform/receivables/graphs_test.go, internal/platform/receivables/transition.go
  • Create: internal/platform/receivables/step.go, internal/platform/receivables/step_test.go
  • Test: internal/platform/einvoice/void_test.go (the D10 table through the real edge)

Interfaces:

  • Consumes: AR’s ls, st, ed, why, seat helpers; workflow.Latest, PublishGraph, provisioning.Register, provisioning.Warning; ownerSeatOf is private to workflow — use orgstruct the way workflow.ownerSeatOf does, or export workflow.OwnerSeat(ctx, tx) (a one-line exported wrapper; preferred — do not copy the selection rule).

  • Produces: v2 InvoiceGraph, CreditNoteGraph; receivables.StepOrderSalesGraphsV2 = 78; receivables.ErrHasAllocations on the void (reuse AR’s sentinel if Task 6 of AR defined one, else add it).

  • Step 1: Replace AR’s TestAnIssuedInvoiceHasNoExit with:

// Spec (MyInvois) D10: issued has exactly one exit — the owner-granted,
// reason-required, reversing void, for an e-Invoice cancelled within LHDN's
// window or refused by LHDN. When it is allowed is the e-Invoice guard's.
func TestAnIssuedDocumentsOnlyExitIsTheReversingVoid(t *testing.T) {
seat := uuid.New()
for _, g := range []workflow.Graph{receivables.InvoiceGraph(seat), receivables.CreditNoteGraph(seat)} {
require.Empty(t, workflow.ValidateGraph(g, "MYR"), g.Kind)
s, _ := g.State("issued")
require.False(t, s.IsTerminal)
var exits []workflow.EdgeSpec
for _, e := range g.Edges {
if e.From == "issued" {
exits = append(exits, e)
}
}
require.Len(t, exits, 1, g.Kind)
x := exits[0]
require.Equal(t, "voided", x.To)
require.True(t, x.RequiresReason && x.ReversesAccrual)
require.Equal(t, workflow.GateTarget, x.Gate)
v, _ := g.State("voided")
require.Equal(t, posting.Cancelled, v.Semantic)
require.True(t, v.IsTerminal)
require.Contains(t, g.Grants, workflow.GrantSpec{StateKey: "voided", GranteeKind: workflow.GranteePosition, PositionID: &seat}.Normalized())
}
}

(If GrantSpec has no Normalized, compare StateKey, GranteeKind, PositionID of the one grant on voided.)

  • Step 2: Run — expect failure.

  • Step 3: Graphs. In both graphs: issued → st(…, "issued", posting.Accrual, false, …); add st(6, "voided", posting.Cancelled, true, ls("Cancelled", "已作废", "Dibatalkan")); add why(ed(6, "issued", "voided", workflow.GateTarget, "void", ls("Void", "作废", "Batalkan")), true); add seat("voided", ownerSeat) to Grants. Update the ASCII diagrams in the doc comments.

  • Step 4: The void’s allocation rule. In TransitionDocument, for from.Semantic == posting.Accrual && to.Semantic == posting.Cancelled: the document’s outstanding must equal its gross (AR’s outstanding helper from its Task 6; for a credit note, its remaining must equal its gross, i.e. its self-allocation reversed first), else ErrHasAllocations. Set cancelled_at as the withdraw path does. Test (in void_test.go, einvoice package, because the void also needs a submission status that allows it): an invalid-status invoice with a receipt allocated → ErrHasAllocations (presence: without the receipt it voids and its accrual is reversed — the entry’s mirror exists).

  • Step 5: void_test.go — D10’s sales column through receivables.TransitionDocument on the real edge, one subtest per status (set with a test helper e.ForceStatus(t, inv, status) that UPDATEs the row directly, bypassing the worker), plus: a Finance member (no seat) is refused by the workflow even when the status allows it (presence: the Owner succeeds).

  • Step 6: step.go — D16:

const StepOrderSalesGraphsV2 = 78
type salesGraphsV2Step struct{}
func init() { provisioning.Register(salesGraphsV2Step{}) }
func (salesGraphsV2Step) Name() string { return "sales_graphs_v2" }
func (salesGraphsV2Step) Order() int { return StepOrderSalesGraphsV2 }
// upgradable: the seeded v1 exactly as seeded — version 1, published by the
// system, no exit from issued. Anything else is the organization's own flow.
func upgradable(d workflow.Definition) bool {
if d.Version != 1 || d.PublishedBy != nil {
return false
}
for _, e := range d.Edges {
if e.From == "issued" {
return false
}
}
return true
}

Run: for each kind, workflow.Latest; upgradable → PublishGraph(ctx, tx, c, build(seat), nil). Check: done when no latest is upgradable; warning (not failure) for a kind whose latest has no edge from issued and is not upgradable: "the %s flow was customized; add the Void exit from Issued in the workflow editor". Mirror payables/step.go for the provisioning.Step method set and its test style.

step_test.go: an org provisioned with AR’s v1 (publish v1 by hand in the test: the v1 graph literal lives in the test file, copied from AR’s graphs before this task) → step publishes v2 (presence of the exit); an org whose v1 was republished by a member → untouched, one warning; a re-run is a no-op.

  • Step 7: Run — expect PASS: go test ./internal/platform/receivables/... ./internal/platform/einvoice/... ./internal/app/... -count=1 (the app’s seeded-graph validator and three-locale tests see the new state).

  • Step 8: Sabotage. (a) Drop ReversesAccrual from the void edge: workflow.ValidateGraph reports reversal_missing and TestAnIssuedDocumentsOnlyExitIsTheReversingVoid red. (b) In upgradable, drop the PublishedBy test: step_test’s customized org is upgraded → red. Restore; record.

  • Step 9: Commit. MyInvois Task 5: AR seeded graphs v2 (void from issued), void allocation rule, provisioning step 78


Task 6: The worker — sweep, submit, poll, retry, window, recovery

Section titled “Task 6: The worker — sweep, submit, poll, retry, window, recovery”

Files:

  • Create: internal/platform/einvoice/worker.go, internal/platform/einvoice/worker_test.go
  • Modify: internal/kernel/jobs/jobs.go (QueueEInvoice), cmd/worker/main.go, cmd/worker/exports.go (register the worker), cmd/worker/wiring_test.go

Interfaces:

  • Consumes: einvoice.CurrentClient, DueOrgs, setStatus, the error types; company.Get (taxpayer); myinvois.NewClient, myinvois.LoadSigner, config.MyInvoisFromEnv.

  • Produces: einvoice.SweepArgs (kind einvoice_sweep), einvoice.SweepPeriodic(), einvoice.SweepWorker{DB *db.DB; Clock clock.Clock; Logger *slog.Logger; Pause func(time.Duration)}, einvoice.RunOrg(ctx, database, c, client, org, pause) error (the per-org pass, exported for tests).

  • Step 1: Failing worker tests (worker_test.go, on einvoicetest.Env, fake clock advanced by hand; RunOrg called directly):

func TestAQueuedInvoiceBecomesValid(t *testing.T) {
e := einvoicetest.New(t); e.InScope(t); e.UseClient(t)
inv := e.IssuedInvoice(t)
require.NoError(t, einvoice.RunOrg(e.Ctx, e.DB, e.Clock, e.Client, e.Org, noPause))
s := e.Submission(t, inv)
require.Equal(t, einvoice.StatusValid, s.Status)
require.NotEmpty(t, s.UUID)
require.Equal(t, e.PortalURL+"/"+s.UUID+"/share/"+*s.LongID, *s.ValidationURL)
require.True(t, s.NextCheckAt.After(e.Clock.Now())) // the window watch is scheduled (M9)
require.Equal(t, s.DocumentHash, sha256hex(s.SubmittedDocument))
}
func TestAnInvalidDocumentKeepsLHDNsErrors(t *testing.T) { /* fake ScriptInvalid → invalid, errors JSON has validationSteps; next_check_at NULL */ }
func TestARejectedAtSubmitDocumentIsInvalid(t *testing.T) { … }
func TestTransientFailuresBackOffThenSucceed(t *testing.T) {
/* ScriptFail(3, 500): run → queued, attempts 1, next_check_at = now+30s; advance; run → +60s; → +120s; advance; run → valid.
The same submitted_document bytes were sent every time (fake.Calls() bodies equal) — M10 */
}
func TestRetryAfterIsHonoured(t *testing.T) { /* 429 Retry-After 7 → but backoff 30s wins at attempt 1; at a larger Retry-After (600) → +600s */ }
func TestTheWindowClosesIntoFailed(t *testing.T) { /* always 500; advance to issued_at+72h-10m → failed/window_expired, next_check_at NULL */ }
func TestAnUnknownOutcomeIsRecoveredNotDuplicated(t *testing.T) {
/* ScriptDropResponse: run → row still submitting (the record tx saw an UnknownOutcomeError), next check +5m;
run → Recent finds internalId → adopted uuid, submitted; run → valid. fake has exactly one document for the code number. */
}
func TestAnUnknownOutcomeNotFoundIsResentAfterTenMinutes(t *testing.T) { /* fake drops the request entirely (never stored): before 10 min: waits; after: resends, valid */ }
func TestTwoCopiesAtTheAuthorityStopForAPerson(t *testing.T) { /* fake holds two docs with the code number → failed/duplicate_unresolved */ }
func TestForbiddenStopsWithACode(t *testing.T) { /* fake 403 → failed/forbidden */ }
func TestNotConfiguredWaitsThenFails(t *testing.T) { /* client nil: stays queued; after the window: failed/not_configured */ }
func TestTheWindowWatchSeesARejectionRequestAndAPortalCancel(t *testing.T) { … }
func TestEveryStatusChangeIsAudited(t *testing.T) { /* audit_log rows for the submission id: queued→submitting→submitted→valid, actor NULL */ }
func TestTheSweepFindsOrganizationsThroughDueOrgs(t *testing.T) { /* two orgs, one due: SweepWorker.Work runs RunOrg for exactly that one (fake.Calls onbehalfof) */ }
func TestNoNetworkCallHappensInsideATransaction(t *testing.T) {
/* the fake's handler asserts, via a hook, that the row it is about to accept is visible as 'submitting' from a separate
pool connection (i.e. committed) — proves submit-mark committed before the call */
}
  • Step 2: Run — expect failure.

  • Step 3: worker.go. The per-org pass, with each network call outside any transaction:

// RunOrg is one organization's pass (spec D8). ORDER per phase: claim rows
// (FOR UPDATE SKIP LOCKED) and mark them in one transaction, commit; call
// the authority with no transaction open; record the outcome in a new
// transaction, audit last.
func RunOrg(ctx context.Context, database *db.DB, c clock.Clock, cl Client, org uuid.UUID, pause func(time.Duration)) error {
ctx = tenancy.WithOrg(ctx, org)
tp, err := taxpayer(ctx, database) // company TIN + BRN
if err != nil {
return err
}
if err := submitDue(ctx, database, c, cl, tp); err != nil {
return err
}
pause(3 * time.Second)
if err := pollDue(ctx, database, c, cl, tp); err != nil {
return err
}
return watchDue(ctx, database, c, cl, tp)
}

submitDue:

  1. tx: SELECT … FROM einvoice_submission WHERE org_id = $1 AND status IN ('queued','submitting') AND next_check_at <= $2 ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED. For a submitting row (a previous pass died mid-call, or recorded an unknown outcome) → collect for recovery, not resend. For a queued row: cl == nil → if past the window failed/not_configured else leave (next check +5 m); past the window → failed/window_expired; else if submitted_document is NULL → cl.Sign(document_json, c.Now()), store bytes, hash, version, first_sent_at; → submitting, attempts+1. Commit.
  2. no tx: recovery rows → cl.Recent(tp, first_sent_at−1m, now), match InternalID == code_number (M12): one match → adopt (submitted, uuid, submission uid); two → failed/duplicate_unresolved; none and first_sent_at older than 10 minutes → move to the resend batch; none and younger → leave submitting, next check +5 m.
  3. no tx: cl.Submit(tp, batch) (batch ≤ 5 MB; a single document > 300 KB is failed/too_large without a call).
  4. tx: record — accepted → submitted, uuid, uid, next_check_at = now (polled in this pass); rejected → invalid, errors, next NULL; TransientError → back to queued, next_check_at = now + backoff(attempts, RetryAfter) (M8), or failed/window_expired when past the window; PermanentError → failed/<code>; UnknownOutcomeError → stay submitting, next +5 m. Every change through setStatus (audit last).

pollDue: submitted rows due, grouped by submission_uid; cl.Submission per uid; Valid → valid with long_id, validated_at, validation_url = cl.ValidationURL(uuid, longID), next = now + 15 m (M9); Invalid → cl.Details once for errors, invalid; still Submitted → next = now + poll backoff (M8); TransientError → next + backoff. watchDue: valid rows due within validated_at + 72h: cl.Details — CancelledAt → cancelled, status_reason “cancelled at the authority”; RejectRequestedAt → record it and the reason once (audit); past the window → next NULL.

SweepWorker.Work: database.Pool() query SELECT einvoice_due_orgs() (no org — the one query without an org_id predicate; add einvoice_due_orgs to the tenant-predicate test’s allowlist of org-less statements in the same way notification_prune() is, if the test flags it), then RunOrg for each; an error for one org is logged and the next org runs; ctx done stops the loop.

backoff(attempts int, retryAfter time.Duration) time.Duration — M8, a table-driven test of its own.

  • Step 4: Wiring. jobs.QueueEInvoice = "einvoice". cmd/worker/main.go: mcfg, err := config.MyInvoisFromEnv() (fatal on error); when mcfg.APIURL != "": signer, err := myinvois.LoadSigner(mcfg.CertFile, mcfg.KeyFile) (fatal on error; log subject and NotAfter, warn within 30 days, and re-log daily from the prune loop’s ticker), client, err := myinvois.NewClient(…), einvoice.UseClient(client); log a warning when mcfg.Loopback. The sweep is registered whether or not a client is configured (queued rows must still reach failed/not_configured when the window closes). startExportWorker gains river.AddWorker(workers, &einvoice.SweepWorker{DB: database, Clock: clock.System(), Logger: cfg.Logger, Pause: time.Sleep}) and the caller adds jobs.QueueEInvoice: 1 and einvoice.SweepPeriodic(). Link internal/modules/einvoice (Task 8 creates it; until then link internal/platform/einvoice blank and add the module import in Task 8). wiring_test.go: workflow.HasGuard true for sales_invoice, customer_credit_note (and, after Task 7, the two AP kinds — Task 7 adds them).

  • Step 5: Run — expect PASS: go test ./internal/platform/einvoice/... ./cmd/worker/... -count=1.

  • Step 6: Sabotage. (a) Sign on every attempt (drop the submitted_document IS NULL test): the trigger refuses the second write — TestTransientFailuresBackOffThenSucceed red with einvoice_submission_frozen. (b) Resend a submitting row without recovery: TestAnUnknownOutcomeIsRecoveredNotDuplicated red (two documents at the fake). (c) Make the submit call inside the claim transaction: TestNoNetworkCallHappensInsideATransaction red. Restore each; record.

  • Step 7: Commit. MyInvois Task 6: worker sweep — submission, polling, retry with backoff, window, recovery


Task 7: Self-billed e-Invoices on the AP side

Section titled “Task 7: Self-billed e-Invoices on the AP side”

Files:

  • Create: db/migrations/00381_payables_self_billed.sql
  • Modify: internal/platform/payables/{invoice.go,supplier.go,doc.go}, internal/modules/payables/{invoice_handlers.go,supplier_handlers.go}, api/openapi.yaml (+ generated), cmd/*/wiring_test.go (two more kinds)
  • Create: internal/platform/einvoice/guard_payables.go
  • Test: internal/platform/payables/{selfbilled_schema_test.go,invoice_test.go additions}, internal/platform/einvoice/selfbilled_test.go, internal/modules/payables/invoice_handlers_test.go additions

Interfaces:

  • Consumes: payables.GetInvoice, GetSupplier, KindInvoice, KindCreditNote, checkSubmission; party.registration_type (AR’s 00310).

  • Produces: payables.BasisSelfBilled = "self_billed"; Invoice.OriginalInvoiceID *uuid.UUID; Line.ClassificationCode *string / LineInput.ClassificationCode; Supplier.RegistrationType *string / SupplierInput.RegistrationType; OpenAPI: document_basis enum + self_billed, line classification_code, invoice original_invoice_id, supplier registration_type.

  • Step 1: Failing schema test — self_billed accepted by purchase_invoice_basis_known (presence) and a self_billed row with einvoice_uuid set refused by purchase_invoice_self_billed_no_identifiers; a credit note with self_billed and no original refused by purchase_invoice_self_billed_credit_note_original; classification_code '36' refused by purchase_invoice_line_classification_shape; original_invoice_id on an approved document refused by purchase_invoice_is_draft (the freeze covers it).

  • Step 2: 00381_payables_self_billed.sql.

-- +goose Up
-- Self-billed e-Invoices (spec 2026-09-27-myinvois-design.md D13).
ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_basis_known;
ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_basis_known CHECK (document_basis IS NULL OR document_basis IN
('einvoice_company','foreign_supplier','relaxation_receipt','self_billed'));
-- The UUID is LHDN's answer and lives on einvoice_submission; the supplier
-- issued nothing.
ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_self_billed_no_identifiers CHECK (
document_basis IS DISTINCT FROM 'self_billed'
OR (supplier_tin IS NULL AND einvoice_uuid IS NULL AND einvoice_validated_at IS NULL));
ALTER TABLE purchase_invoice ADD COLUMN original_invoice_id UUID;
ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_original_fk
FOREIGN KEY (org_id, original_invoice_id) REFERENCES purchase_invoice (org_id, id);
ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_original_only_credit_note CHECK (
original_invoice_id IS NULL OR kind = 'supplier_credit_note');
ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_self_billed_credit_note_original CHECK (
kind <> 'supplier_credit_note' OR document_basis IS DISTINCT FROM 'self_billed' OR original_invoice_id IS NOT NULL);
ALTER TABLE purchase_invoice_line ADD COLUMN classification_code TEXT
CONSTRAINT purchase_invoice_line_classification_shape CHECK (classification_code IS NULL OR classification_code ~ '^[0-9]{3}$');
-- The freeze covers the new header column (the line guard already fires on
-- every INSERT/UPDATE of a line).
DROP TRIGGER purchase_invoice_draft_guard ON purchase_invoice;
CREATE TRIGGER purchase_invoice_draft_guard
BEFORE UPDATE OF supplier_party_id, supplier_invoice_no, invoice_date, due_on, currency, fx_rate, gross_amount,
gross_base, document_id, document_basis, supplier_tin, einvoice_uuid, einvoice_validated_at, relaxation_reason,
original_invoice_id
ON purchase_invoice FOR EACH ROW EXECUTE FUNCTION purchase_invoice_draft_guard();
-- +goose Down
DROP TRIGGER purchase_invoice_draft_guard ON purchase_invoice;
CREATE TRIGGER purchase_invoice_draft_guard
BEFORE UPDATE OF supplier_party_id, supplier_invoice_no, invoice_date, due_on, currency, fx_rate, gross_amount,
gross_base, document_id, document_basis, supplier_tin, einvoice_uuid, einvoice_validated_at, relaxation_reason
ON purchase_invoice FOR EACH ROW EXECUTE FUNCTION purchase_invoice_draft_guard();
ALTER TABLE purchase_invoice_line DROP COLUMN classification_code;
ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_self_billed_credit_note_original;
ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_original_only_credit_note;
ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_original_fk;
ALTER TABLE purchase_invoice DROP COLUMN original_invoice_id;
ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_self_billed_no_identifiers;
ALTER TABLE purchase_invoice DROP CONSTRAINT purchase_invoice_basis_known;
ALTER TABLE purchase_invoice ADD CONSTRAINT purchase_invoice_basis_known CHECK (document_basis IS NULL OR document_basis IN
('einvoice_company','foreign_supplier','relaxation_receipt'));

Update the AP test that asserts the basis list (invoice_schema_test.go:118 refuses einvoice_employee — still true; add self_billed accepted).

  • Step 3: Failing platform tests (invoice_test.go): self_billed handled before taxrecord.Normalize (which does not know it): saved with supplier_tin/einvoice_uuid supplied → 422 self_billed_identifiers (“a self-billed e-Invoice gets its identifier from LHDN; leave these empty”); a credit note with self_billed needs original_invoice_id naming an approved self-billed invoice of the same supplier (422 original_invoice otherwise; presence: a correct one saves); classification_code shape-checked on save when present; at submit (checkSubmission), a self-billed document with a line lacking a classification code is refused (lines[i].classification_code, required) — presence: all lines coded submits. Supplier registration_type ∈ brn nric passport army (422 otherwise) and needs registration_no (AR’s rule on party).

  • Step 4: Payables changes. doc.go: BasisSelfBilled. invoice.go: Invoice.OriginalInvoiceID, Line.ClassificationCode, LineInput.ClassificationCode; select/scan/insert them; the self-billed branch in the input check; checkSubmission adds the classification rule. supplier.go: RegistrationType read/write on party (the column AR added). No change to transition.go — the guard is in the engine.

  • Step 5: Failing guard tests (selfbilled_test.go, on einvoicetest.Env + payablestest helpers: the env embeds both worlds, or build a small AP helper on the same expensestest.Env):

func TestASelfBilledInvoiceIsQueuedAtApprovalNotAtSubmit(t *testing.T) {
e := einvoicetest.New(t); e.InScope(t)
sup := e.ForeignSupplier(t) // TIN EI00000000030, passport, SG address, phone
pi := e.SelfBilledInvoice(t, sup, "036")
e.SubmitPurchase(t, pi)
require.Nil(t, e.PurchaseSubmissionOrNil(t, pi)) // submit: checked, not queued
e.ApprovePurchase(t, pi)
s := e.PurchaseSubmission(t, pi)
require.Equal(t, einvoice.TypeSelfBilledInvoice, s.DocumentType)
require.Nil(t, e.PurchaseInvoice(t, pi).EInvoiceUUID) // never written back to the frozen invoice
}
func TestSelfBilledNeedsScope(t *testing.T) { /* out of scope: submit refused 422 self_billed_needs_scope (presence: in scope it submits) */ }
func TestAnIncompleteSelfBilledSupplierIsRefusedAtSubmit(t *testing.T) { /* no phone → supplier.phone */ }
func TestAnOrdinaryPurchaseInvoiceIsUntouched(t *testing.T) { /* basis einvoice_company in an in-scope org: no row; its void works as before */ }
func TestTheSelfBilledVoidRule(t *testing.T) { /* D10's AP column through payables.TransitionInvoice */ }
func TestASelfBilledCreditNoteReferencesItsOriginal(t *testing.T) { /* type 12, BillingReference = original's uuid; original pending → 409 */ }
  • Step 6: guard_payables.go. init() registers purchaseGuard for payables.KindInvoice, payables.KindCreditNote. Void: voidAllowed(ForPurchase(...), false). Otherwise: load the invoice; not self_billed → nil; out of scope → FieldError{"document_basis", "self_billed_needs_scope", …} wrapped in CheckError; build the Document (Supplier = the party’s fields, MSIC "00000", SST/registration NA when empty per D13; Buyer = company + setting; lines quantity 1, unit price = net (money.RateFromAmount or equivalent — check internal/kernel/money for the existing conversion and use it), tax type from the line’s tax code’s pack_kind via chart.GetTaxCode; IssuedAt = c.Now(), CodeNumber = Reference); Check; on To.Semantic == posting.Accrual → originalReady for credit notes, Encode, queue.

  • Step 7: API. api/openapi.yaml: AP schemas gain the four fields (enum self_billed on every document_basis enum that belongs to AP — lines 9474 and 10644 today; not the claim line’s at 8991), make generate, handlers map them. Handler test: a self-billed invoice round-trips its classification codes; the list/filter output unchanged otherwise.

  • Step 8: Wiring tests — cmd/*/wiring_test.go assert workflow.HasGuard for purchase_invoice, supplier_credit_note.

  • Step 9: Run — expect PASS: go test ./internal/platform/payables/... ./internal/platform/einvoice/... ./internal/modules/payables/... -count=1.

  • Step 10: Sabotage. (a) Queue in the submit branch too: TestASelfBilledInvoiceIsQueuedAtApprovalNotAtSubmit red. (b) Remove original_invoice_id from the trigger’s column list: the freeze test red (the UPDATE succeeds). Restore; record.

  • Step 11: Commit. MyInvois Task 7: self-billed e-Invoices on purchase invoices and supplier credit notes


Task 8: The einvoice module — settings, submissions, retry, cancel

Section titled “Task 8: The einvoice module — settings, submissions, retry, cancel”

Files:

  • Create: internal/modules/einvoice/{boot.go,permissions.go,routes.go,http.go,errors.go,setting_handlers.go,submission_handlers.go} + tests
  • Create: db/migrations/00382_einvoice_role_grants.sql
  • Modify: constants.yaml (+ generated vocab), api/openapi.yaml (+ generated), internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/*/wiring_test.go, internal/modules/receivables/errors.go, internal/modules/payables/errors.go (+ their tests)

Interfaces:

  • Consumes: everything in internal/platform/einvoice; receivables.TransitionDocument, payables.TransitionInvoice; workflow.Get, workflow.Load; myinvois.Codec, myinvois.NewClient, config.MyInvoisFromEnv.

  • Produces: routes GET/PUT /einvoice/settings, GET /einvoice/classification-codes, GET /einvoice/submissions, GET /einvoice/submissions/{id}, POST /einvoice/submissions/{id}/retry, POST /einvoice/submissions/{id}/cancel; permissions einvoice.read, einvoice.submission.manage, einvoice.settings.manage.

  • Step 1: Permissions and boot. permissions.go mirrors internal/modules/payables/permissions.go: three member.RegisterPermission calls (descriptions: “Read e-Invoice submissions and settings”; “Retry a failed e-Invoice submission and cancel a validated e-Invoice; the document’s own void grant is still required”; “Switch e-Invoicing on or off and edit the seller’s e-Invoice details”), provisioning.RegisterRolePermissions("Finance", read, submission.manage). boot.go: func init() { einvoice.RegisterCodec("MY", myinvois.Codec{}) } and a blank import of internal/platform/einvoice (for its guards). constants.yaml gains the three keys. 00382 mirrors 00305 (Owner: three keys; Finance: two; bump perm_version; Down deletes einvoice.%).

  • Step 2: OpenAPI (first, then make generate): schemas EInvoiceSettings (in_scope, in_scope_since, seller {tin, registration_no, sst_registration_no, msic_code, business_activity, phone, email, address {line1, line2, city, postcode, state_code, country_code}}, authority_configured, authority_is_fake, revision), EInvoiceSettingsUpdate, EInvoiceSubmission (id, source_kind, source_id, document_type, code_number, counterparty_name, status, failure_code, uuid, validation_url, validated_at, cancelled_at, reject_requested_at, status_reason, errors (free-form object), attempts, next_check_at, issued_at, window_closes_at, cancellable_until, available_actions (retry, cancel)), EInvoiceSubmissionPage, EInvoiceCancelRequest {reason} (1–300 chars), ClassificationCode {code, description}. 409 responses carry code (window_closed, has_credit_notes, einvoice_not_configured, not_cancellable, not_retryable, revision_mismatch). If-Match on the settings PUT.

  • Step 3: Failing handler tests (submission_handlers_test.go, mirror internal/modules/payables/invoice_handlers_test.go’s world):

func TestCancellingAValidInvoiceCancelsAtLHDNAndVoidsIt(t *testing.T) {
w := newWorld(t) // in scope, fake client in use, an issued invoice run to valid by einvoice.RunOrg
res := w.do(t, "POST", "/v1/einvoice/submissions/"+w.sub+"/cancel", `{"reason":"Wrong customer"}`, w.owner)
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, "cancelled", jsonField(res, "status"))
require.Equal(t, "voided", w.invoiceState(t))
require.True(t, w.accrualReversed(t))
require.Equal(t, 1, w.fake.Count("PUT", "/api/v1.0/documents/state/"))
}
// The rehearsal proves refusals before LHDN is touched (spec D10).
func TestAMemberWithoutTheVoidGrantIsRefusedBeforeLHDNIsCalled(t *testing.T) {
w := newWorld(t)
res := w.do(t, "POST", "/v1/einvoice/submissions/"+w.sub+"/cancel", `{"reason":"Wrong customer"}`, w.finance)
require.Equal(t, http.StatusForbidden, res.Code) // the workflow's RefusedError, mapped as AR maps it
require.Equal(t, 0, w.fake.Count("PUT", "/api/v1.0/documents/state/"))
require.Equal(t, "issued", w.invoiceState(t))
}
func TestAfterSeventyTwoHoursCancelIsRefusedAndNothingChanges(t *testing.T) { /* advance clock 73h: 409 window_closed; fake called once, refused; state issued, row valid */ }
func TestCancelIsOnlyForAValidRow(t *testing.T) { /* queued → 409 not_cancellable (no LHDN call) */ }
func TestRetryRequeuesAFailedRowInsideTheWindow(t *testing.T) { /* failed/forbidden → queued, next_check_at now; after the window 409 not_retryable */ }
func TestTheListFiltersByStatusAndSource(t *testing.T) { … }
func TestSettingsNeedTheSettingsPermission(t *testing.T) { /* Finance PUT → 403 (presence: Owner 200) */ }
func TestTheVoidEdgeSaysCancelFirst(t *testing.T) {
/* POST /v1/receivables/invoices/{id}/transition to voided on a valid row → 409 code einvoice_cancel_first (receivables mapErr) */
}
  • Step 4: submission_handlers.go — cancel with rehearsal (M13):
var errRehearsal = errors.New("einvoice: rehearsal rolls back")
// cancel is spec D10: rehearse the void (rolled back), cancel at the
// authority, then void for real. No transaction is open during the call.
func (h handlers) cancel(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var body api.EInvoiceCancelRequest
if !bindJSON(c, &body) {
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" || len(reason) > 300 || recordtext.Check("reason", reason) != nil {
_ = c.Error(apperr.New(apperr.KindValidation, "field=reason rule=record_language: a reason of up to 300 characters, in English or Bahasa Malaysia"))
return
}
cl, err := einvoice.CurrentClient()
if err != nil {
_ = c.Error(apperr.New(apperr.KindUnavailable, "e-Invoicing is not configured on this server").With("code", "einvoice_not_configured"))
return
}
actor := httpx.MemberFrom(c) // the helper AR/AP handlers use
ctx := c.Request.Context()
var s einvoice.Submission
var tp einvoice.Taxpayer
// ORDER (lock order): mark the row (no audit) → the source's void
// (workflow.Transition, Poster, the source's own audit) → the row's audit.
void := func(ctx context.Context, tx pgx.Tx) error {
var err error
if s, err = einvoice.MarkCancelled(ctx, tx, h.deps.Clock, id, reason); err != nil { // refuses unless valid and within 72h of validated_at; writes no audit
return err
}
if tp, err = einvoice.TaxpayerOf(ctx, tx); err != nil {
return err
}
if err := h.voidSource(ctx, tx, s, actor, reason); err != nil { // receivables.TransitionDocument / payables.TransitionInvoice to the reversing cancelled state
return err
}
return einvoice.AuditCancelled(ctx, tx, h.deps.Clock, s)
}
if err := h.deps.DB.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := void(ctx, tx); err != nil {
return err
}
return errRehearsal
}); !errors.Is(err, errRehearsal) {
_ = c.Error(mapErr(ledger.MapError(err)))
return
}
if err := cl.Cancel(ctx, tp, s.UUID, reason); err != nil {
var ref *einvoice.AuthorityRefusal
if !(errors.As(err, &ref) && ref.Code == "incorrect_state" && alreadyCancelled(ctx, cl, tp, s.UUID)) {
_ = c.Error(mapErr(err))
return
}
}
if err := h.deps.DB.InTx(ctx, void); err != nil {
// LHDN has cancelled; the window watch will mark the row cancelled
// and the ordinary void will then succeed (spec D10).
_ = c.Error(mapErr(ledger.MapError(err)))
return
}
h.respondSubmission(c, id)
}

voidSource finds the reversing edge from the instance’s current state (the one edge whose target semantic is cancelled and ReversesAccrual; by semantic, never by key) and calls the source’s transition. einvoice.MarkCancelled updates the row (status cancelled, cancelled_at, status_reason, next NULL) and writes no audit row — it runs before workflow.Transition, whose Poster lock must precede the audit chain lock; einvoice.AuditCancelled writes it afterwards.

retry: einvoice.Retry(ctx, tx, c, id) — failed and c.Now() < issued_at + 72h − 15m → queued, attempts kept, failure_code NULL, next = now; else ConflictError{not_retryable}; audited.

available_actions on the DTO: retry when failed and inside the window and the caller holds einvoice.submission.manage; cancel when valid, inside 72 h of validated_at and the caller holds it (the void grant is checked on use).

  • Step 5: Settings handlers over einvoice.GetSetting/UpdateSetting (ETag = revision); authority_configured = einvoice.CurrentClient() succeeds; authority_is_fake = the config’s Loopback. GET /einvoice/classification-codes returns the pack’s 45 (read through a Codes() method on myinvois.Codec, or a package function; used by both web forms).

  • Step 6: Error mapping. internal/modules/einvoice/errors.go and both AR’s and AP’s mapErr gain, before their default: *einvoice.CheckError → 422 (joined problems); *einvoice.FieldError → 422; *einvoice.ConflictError → 409 With("code", e.Code); *einvoice.AuthorityRefusal → 409 window_closed / has_credit_notes; *einvoice.TransientError → 503 “LHDN did not answer; try again”; einvoice.ErrNotConfigured → 503 einvoice_not_configured. A test per module: an in-scope issue with a gap returns 422 with field=buyer.tin.

  • Step 7: Composition. internal/app/app.go registers the module’s routes (mirror the payables module’s registration); routes_test.go expectations. cmd/api/main.go: config.MyInvoisFromEnv(); when set, myinvois.NewClient(cfg without cert, clock.System()) → einvoice.UseClient (the API never signs); warn when loopback. cmd/{api,migrate,orgadmin,worker}/main.go blank-import internal/modules/einvoice; wiring_test.go asserts einvoice.HasCodec("MY").

  • Step 8: Run — expect PASS: go test ./internal/modules/einvoice/... ./internal/modules/receivables/... ./internal/modules/payables/... ./internal/app/... ./cmd/... -count=1.

  • Step 9: Sabotage. (a) Skip the rehearsal: TestAMemberWithoutTheVoidGrantIsRefusedBeforeLHDNIsCalled red (fake count 1). (b) Register /einvoice/settings PUT with einvoice.read: TestTheOpenAPIDocumentSaysWhatTheRouterEnforces / TestSettingsNeedTheSettingsPermission red. (c) Remove the blank import from cmd/orgadmin/main.go: its wiring_test red. Restore each; record.

  • Step 10: Commit. MyInvois Task 8: e-Invoice module — settings, submissions, retry, cancel with rehearsal


Task 9: The validated UUID and QR code on the invoice PDF

Section titled “Task 9: The validated UUID and QR code on the invoice PDF”

Files:

  • Modify: internal/platform/reporting/render/render.go, internal/platform/reporting/render/pdf.go, internal/platform/reporting/render/pdf_test.go, go.mod, go.sum
  • Modify: internal/modules/receivables/pdf.go, internal/modules/receivables/pdf_test.go

Interfaces:

  • Consumes: einvoice.ForSales, einvoice.GetSetting; github.com/boombuler/barcode, github.com/boombuler/barcode/qr.

  • Produces: render.Meta.QR string.

  • Step 1: Failing render test. pdf_test.go: a Meta{QR: "https://preprod.myinvois.hasil.gov.my/X/share/Y"} PDF contains an image XObject (/Subtype /Image) and one without QR does not (presence first: the QR case).

  • Step 2: Promote the dependency. go get github.com/boombuler/barcode@<the version already in go.sum>; go mod tidy must move it to the direct block and change nothing else (check git diff go.mod). No new module.

  • Step 3: Draw it. In pdfWriter.newPage, page 1 only, when p.meta.QR != "": qr.Encode(p.meta.QR, qr.M, qr.Auto) → barcode.Scale(code, 288, 288) → png.Encode to a buffer → gopdf.ImageHolderByBytes → p.pdf.ImageByHolder(holder, p.page.W-pdfMargin-72, pdfMargin, &gopdf.Rect{W: 72, H: 72}) (M18). The title and fields keep their positions (the QR sits in the right margin area; if a field line would run under it, its width is not changed — fields are short). CSV/XLSX ignore QR. Comment: “float64 here is page geometry only (the file’s existing exemption).”

  • Step 4: Failing AR PDF tests (internal/modules/receivables/pdf_test.go):

func TestAValidatedInvoicesPDFCarriesItsUUIDLinkAndQR(t *testing.T) {
w := newEInvoiceWorld(t) // in scope; invoice issued and run to valid against the fake
pdf := w.do(t, "GET", "/v1/receivables/invoices/"+w.inv+"/pdf", "", w.clerk)
require.Equal(t, http.StatusOK, pdf.Code)
text := pdfText(t, pdf.Body.Bytes()) // the existing test helper that inflates content streams, or add one
require.Contains(t, text, "e-Invoice UUID: "+w.uuid)
require.Contains(t, text, "Validation link: "+w.link)
require.Contains(t, string(pdf.Body.Bytes()), "/Subtype /Image")
}
func TestAPendingInvoicesPDFSaysSoAndHasNoQR(t *testing.T) { /* queued: "e-Invoice: pending validation", no image; presence: the valid case above */ }
func TestAnOutOfScopeInvoicesPDFIsUnchanged(t *testing.T) { /* no e-Invoice line at all */ }
  • Step 5: pdf.go. Inside the read-only transaction, after receivables.GetDocument: s, err := einvoice.ForSales(ctx, tx, d.ID); renderDocument(d, s) appends, when s != nil: valid → fields {"e-Invoice UUID", s.UUID}, {"Validated at", s.ValidatedAt in Asia/Kuala_Lumpur, "2006-01-02 15:04"}, {"Validation link", *s.ValidationURL} and Meta.QR = *s.ValidationURL; cancelled → {"e-Invoice", "cancelled"}; invalid/failed → {"e-Invoice", "not validated"}; otherwise {"e-Invoice", "pending validation"}. English labels (record language, AR D14’s rule).

  • Step 6: Run — expect PASS: go test ./internal/platform/reporting/render/... ./internal/modules/receivables/... -count=1.

  • Step 7: Sabotage. Set Meta.QR only when the status is submitted: TestAValidatedInvoicesPDFCarriesItsUUIDLinkAndQR red. Restore; record.

  • Step 8: Commit. MyInvois Task 9: validated UUID, link and QR code on the invoice PDF


Task 10: Web — e-Invoices page, settings, the document panel

Section titled “Task 10: Web — e-Invoices page, settings, the document panel”

Files:

  • Create: web/src/features/einvoice/{api.ts,EInvoicePage.tsx,SubmissionsTab.tsx,SettingsTab.tsx,EInvoicePanel.tsx,CancelDialog.tsx,routes.tsx} + __tests__/
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/einvoice.json
  • Modify: web/src/app/shell/nav.ts, web/src/app/routes.ts, the AR sales document drawer (web/src/features/receivables/…Drawer.tsx), web/src/features/payables/InvoiceDrawer.tsx

Interfaces:

  • Consumes: the generated client for Task 8’s routes; DataTable (tableId="einvoice.submissions"), the existing status chip, Tabs, dialog and form components used by web/src/features/payables.

  • Produces: useEInvoiceSettings, useUpdateEInvoiceSettings, useSubmissions(filter), useSubmissionForSource(sourceId), useRetrySubmission, useCancelEInvoice, useClassificationCodes; <EInvoicePanel sourceId />.

  • Step 1: Failing component tests (Vitest + MSW, mirror web/src/features/payables/__tests__): the panel renders each status (queued “Menunggu”, valid with UUID + copy link, invalid with LHDN’s messages, failed with the failure sentence and Retry, cancelled); Cancel e-Invoice opens the dialog, requires a reason, posts it, and shows the 409 window_closed sentence; the Submissions tab filters by status; the Settings tab refuses save with field errors from a 422 and asks confirmation before switching in scope on; a authority_is_fake banner shows.

  • Step 2: Build it. Nav: one item appended to the accounting group (/accounting/einvoice, icon ReceiptText, permission einvoice.read), per the nav’s append-only rule. EInvoicePage = Tabs Submissions | Settings (Settings visible with einvoice.settings.manage, read-only with einvoice.read). Panel mounted in both drawers under the document’s header when a submission exists (hidden otherwise; for an in-scope draft, nothing). The status chip colours follow 2026-09-26-status-colours-design.md (queued/submitting/submitted = pending, valid = success, invalid/failed = danger, cancelled = neutral). Money is not shown here (the drawers already show it).

  • Step 3: Translations — einvoice.json. English / Malay (genuine) / Chinese; the keys and strings to use:

key en ms zh
nav e-Invoices e-Invois 电子发票
tabs.submissions Submissions Penghantaran 提交记录
tabs.settings Settings Tetapan 设置
status.queued Queued Dalam giliran 排队中
status.submitting Sending Sedang dihantar 发送中
status.submitted Awaiting validation Menunggu pengesahan 等待验证
status.valid Valid Sah 有效
status.invalid Invalid Tidak sah 无效
status.cancelled Cancelled Dibatalkan 已取消
status.failed Not sent Tidak dihantar 未发送
failure.window_expired LHDN’s 72-hour submission window closed. Void this document and issue a new one. Tempoh 72 jam penghantaran LHDN telah tamat. Batalkan dokumen ini dan keluarkan dokumen baharu. LHDN 的 72 小时提交期限已过。请作废此单据并重新开具。
failure.forbidden LHDN refused us as this organization’s intermediary. Authorize the intermediary in the MyInvois portal, then retry. LHDN menolak kami sebagai perantara organisasi ini. Beri kebenaran kepada perantara dalam portal MyInvois, kemudian cuba semula. LHDN 拒绝我们作为本组织的中介。请在 MyInvois 门户授权中介后重试。
failure.rejected_structure LHDN could not read the document. Contact support. LHDN tidak dapat membaca dokumen ini. Hubungi pihak sokongan. LHDN 无法读取此单据。请联系支持人员。
failure.too_large The document is larger than LHDN accepts (300 KB). Dokumen ini melebihi saiz yang diterima LHDN (300 KB). 单据超过 LHDN 接受的大小(300 KB)。
failure.duplicate_unresolved LHDN holds two copies of this document. Cancel the extra one in the MyInvois portal. LHDN menyimpan dua salinan dokumen ini. Batalkan salinan tambahan dalam portal MyInvois. LHDN 存有此单据的两份副本。请在 MyInvois 门户取消多余的一份。
failure.not_configured e-Invoicing is not configured on this server. e-Invois belum dikonfigurasi pada pelayan ini. 此服务器尚未配置电子发票。
panel.uuid e-Invoice UUID UUID e-Invois 电子发票 UUID
panel.validatedAt Validated Disahkan 验证时间
panel.link Validation link Pautan pengesahan 验证链接
panel.copy Copy link Salin pautan 复制链接
panel.rejectRequested The buyer asked to reject this e-Invoice: {{reason}} Pembeli meminta e-Invois ini ditolak: {{reason}} 买方要求拒收此电子发票:{{reason}}
panel.cancellableUntil Can be cancelled until {{time}} Boleh dibatalkan sehingga {{time}} 可在 {{time}} 前取消
actions.retry Retry Cuba semula 重试
actions.cancel Cancel e-Invoice Batalkan e-Invois 取消电子发票
cancel.title Cancel this e-Invoice at LHDN? Batalkan e-Invois ini di LHDN? 在 LHDN 取消此电子发票?
cancel.body LHDN is told first; then the document is voided and its entry reversed. This cannot be undone. LHDN dimaklumkan dahulu; kemudian dokumen dibatalkan dan catatannya diterbalikkan. Tindakan ini tidak boleh dibatalkan. 先通知 LHDN,然后作废单据并冲销其分录。此操作无法撤销。
cancel.reason Reason (sent to LHDN) Sebab (dihantar kepada LHDN) 原因(将发送给 LHDN)
errors.window_closed More than 72 hours have passed since validation. Issue a credit note instead. Lebih 72 jam telah berlalu sejak pengesahan. Keluarkan nota kredit sebagai ganti. 距验证已超过 72 小时。请改为开具贷记单。
errors.has_credit_notes A credit note refers to this e-Invoice. Cancel the credit note first. Nota kredit merujuk kepada e-Invois ini. Batalkan nota kredit itu dahulu. 有贷记单引用此电子发票。请先取消该贷记单。
errors.einvoice_cancel_first Cancel the e-Invoice first. Batalkan e-Invois dahulu. 请先取消电子发票。
errors.einvoice_in_progress The e-Invoice is still being validated. Try again in a minute. e-Invois masih dalam pengesahan. Cuba lagi sebentar lagi. 电子发票仍在验证中,请稍后再试。
errors.correct_by_credit_note This document was not e-Invoiced. Correct it with a credit note. Dokumen ini tidak dihantar sebagai e-Invois. Betulkan dengan nota kredit. 此单据未开具电子发票。请用贷记单更正。
settings.inScope This organization must issue e-Invoices Organisasi ini wajib mengeluarkan e-Invois 本组织须开具电子发票
settings.inScopeHelp From now on, every invoice and credit note you issue is sent to LHDN’s MyInvois. Documents issued before are not sent. Mulai sekarang, setiap invois dan nota kredit yang dikeluarkan akan dihantar ke MyInvois LHDN. Dokumen yang dikeluarkan sebelum ini tidak dihantar. 从现在起,您开具的每张发票和贷记单都会发送到 LHDN 的 MyInvois。之前开具的单据不会发送。
settings.confirmOn Switch e-Invoicing on? Hidupkan e-Invois? 启用电子发票?
settings.fake This server talks to a local test double, not LHDN. Pelayan ini berhubung dengan pengganti ujian tempatan, bukan LHDN. 此服务器连接的是本地测试替身,而非 LHDN。
settings.businessActivity Business activity (as registered with LHDN) Aktiviti perniagaan (seperti didaftarkan dengan LHDN) 业务活动(与 LHDN 登记一致)
settings.msic MSIC code Kod MSIC MSIC 代码
settings.tin Tax identification number (TIN) Nombor pengenalan cukai (TIN) 税务识别号(TIN)
settings.brn Business registration number Nombor pendaftaran perniagaan 商业登记号
settings.sst SST registration number Nombor pendaftaran SST SST 登记号
settings.state State Negeri 州属

(Remaining field labels — phone, e-mail, address lines, city, postcode, country — reuse the existing keys in web/src/lib/i18n/locales/*/payables.json for the supplier form; import them from there rather than duplicating.)

  • Step 4: Run pnpm -C web test --run src/features/einvoice and pnpm -C web test --run src/lib/i18n (parity) and pnpm -C web lint && pnpm -C web typecheck.

  • Step 5: Sabotage. Delete status.submitting from ms/einvoice.json: i18nparity.test.ts red naming the key. Restore; record.

  • Step 6: Commit. MyInvois Task 10: web e-Invoices page, settings and the document panel


Task 11: Web — self-billed purchase invoices and the supplier’s identification

Section titled “Task 11: Web — self-billed purchase invoices and the supplier’s identification”

Files:

  • Modify: web/src/features/payables/{InvoiceDialog.tsx,SupplierDialog.tsx,api.ts}, web/src/features/payables/__tests__/*, web/src/lib/i18n/locales/{en,ms,zh}/payables.json

  • Step 1: Failing tests. The invoice dialog offers “Self-billed e-Invoice” as a document basis only when the organization is in scope (from useEInvoiceSettings); choosing it hides the supplier TIN / e-Invoice UUID inputs, shows a classification select per line (options from useClassificationCodes, default 036), and on a credit note shows the original invoice select (approved self-billed invoices of the same supplier, from the existing invoice list hook with filters); the supplier dialog has a registration type select (BRN / NRIC / Passport / Army) that requires a registration number.

  • Step 2: Build it. Keys (en / ms / zh): basis.self_billed “Self-billed e-Invoice (we issue it for the supplier)” / “e-Invois bil kendiri (kami keluarkan bagi pihak pembekal)” / “自开电子发票(由我方代供应商开具)”; line.classification “Classification” / “Klasifikasi” / “分类”; original “Original self-billed invoice” / “Invois bil kendiri asal” / “原自开发票”; registrationType “ID type” / “Jenis pengenalan” / “证件类型”; registrationType.brn “Business registration” / “Pendaftaran perniagaan” / “商业登记”; .nric “MyKad (NRIC)” / “MyKad (NRIC)” / “身份证(NRIC)”; .passport “Passport” / “Pasport” / “护照”; .army “Army ID” / “Kad pengenalan tentera” / “军人证”.

  • Step 3: Run the payables feature tests, parity, lint, typecheck.

  • Step 4: Commit. MyInvois Task 11: web self-billed purchase invoices; supplier ID type


Task 12: End to end on lane 7, the fake in dev-stack, roadmap, deploy notes, full suite

Section titled “Task 12: End to end on lane 7, the fake in dev-stack, roadmap, deploy notes, full suite”

Files:

  • Modify: scripts/dev-stack.sh

  • Create: web/e2e/einvoice.spec.ts

  • Modify: web/e2e/support.ts (helpers only if needed), docs/ROADMAP.md, docs/DEPLOY_NOTES.md

  • Step 1: The fake in dev-stack. After source "$STATE/env":

Terminal window
# Lane e2e only: a local fake MyInvois (cmd/myinvois-fake) — never LHDN.
FAKE_PID=""
if [[ -n "${ERP_MYINVOIS_FAKE_PORT:-}" ]]; then
go build -o "$STATE/myinvois-fake" ./cmd/myinvois-fake
"$STATE/myinvois-fake" -addr "127.0.0.1:$ERP_MYINVOIS_FAKE_PORT" &
FAKE_PID=$!
export MYINVOIS_API_URL="http://127.0.0.1:$ERP_MYINVOIS_FAKE_PORT"
export MYINVOIS_IDENTITY_URL="$MYINVOIS_API_URL"
export MYINVOIS_PORTAL_URL="$MYINVOIS_API_URL/portal"
export MYINVOIS_CLIENT_ID=fake-intermediary
export MYINVOIS_CLIENT_SECRET=fake-intermediary-secret
fi

and cleanup also kills $FAKE_PID. The user’s own dev stack (no ERP_MYINVOIS_FAKE_PORT) is unchanged.

  • Step 2: The e2e web/e2e/einvoice.spec.ts (run with ERP_STACK=erp-l7 ERP_PG_PORT=56482 ERP_MINIO_PORT=59170 ERP_API_PORT=3170 ERP_WEB_PORT=5198 ERP_MYINVOIS_FAKE_PORT=3177 pnpm -C web e2e einvoice.spec.ts):
  1. As the Owner: Accounting → e-Invoices → Settings; fill TIN C20880050010, BRN, MSIC 62010, business activity, phone, address (Selangor → state 10), country MY; switch in scope on, confirm; the fake banner is visible.
  2. Create a customer with TIN, BRN, address in “Selangor”, phone.
  3. Create a sales invoice for them (one line, ST10, classification 022), issue it.
  4. In the invoice drawer the e-Invoice panel shows “Awaiting validation” then “Valid” (poll up to 60 s: the sweep is 15 s) with a UUID and a validation link.
  5. Download the PDF (200, application/pdf).
  6. Cancel e-Invoice with a reason → the panel shows “Cancelled” and the document’s status “Cancelled”; Accounting → e-Invoices → Submissions lists it as Cancelled.
  • Step 3: Dev seed. None needed: the flow creates its own settings and customer. web/e2e/seed.ts is untouched unless Finance needs the new keys for another spec (it does not).

  • Step 4: DEPLOY_NOTES — a “MyInvois (Phase 2)” section:

    • Migrations 00380–00382. Re-run provisioning for every organization (POST /v1/org/provision): step 78 moves unedited sales flows to v2 and warns for customized ones.
    • Worker needs MYINVOIS_API_URL, MYINVOIS_IDENTITY_URL, MYINVOIS_PORTAL_URL, MYINVOIS_CLIENT_ID, MYINVOIS_CLIENT_SECRET, and for signing MYINVOIS_SIGNING_CERT_FILE + MYINVOIS_SIGNING_KEY_FILE (Docker secrets, mode 0400, worker container only, excluded from backups). API needs the first five (cancellation), never the key.
    • Converting the CA’s .p12: openssl pkcs12 -in cert.p12 -clcerts -nokeys -out cert.pem and openssl pkcs12 -in cert.p12 -nocerts -nodes | openssl pkcs8 -topk8 -nocrypt -out key.pem; delete intermediate copies.
    • Put the certificate’s expiry (logged by the worker at boot) in the operator’s calendar.
    • Customer onboarding: each organization appoints the provider as intermediary in the MyInvois portal before switching “in scope” on.
    • Go-live checklist — against the LHDN sandbox, before any customer is switched in scope (every item was verified only against our fake): (1) login as intermediary onbehalfof a sandbox TIN; (2) submit and validate one of each type 01, 02, 11, 12; (3) one deliberately invalid document shows LHDN’s errors; (4) one signed version 1.1 document validates (signature shape, documentHash hex); (5) cancel one within 72 hours; (6) the PDF’s QR opens the document in the sandbox portal; (7) the credit note of a pre-mandate invoice with UUID NA is accepted or the rule is changed; (8) re-read the code tables (states, classifications, tax types) against the SDK; (9) go test -tags myinvois_sandbox ./internal/platform/countrypack/my/myinvois/... passes. Record the date and the operator in the runbook.
    • Never point MYINVOIS_API_URL at a loopback address in production (the fake validates everything).
  • Step 5: ROADMAP. Tick “MyInvois: submission, API validation, status polling, retry” and “Self-billed e-Invoice on the AP side”, each with Design / Plan / Deploy links and the migration numbers, plus sub-bullets: “Tested against a local fake only — the DEPLOY_NOTES sandbox checklist must pass before go-live”; “Open decisions (owner’s call)”: the thirteen lines of the spec’s “For the owner to confirm”, one line each; “Deferred”: consolidated e-Invoices, debit/refund notes, inbound e-Invoices, taxpayer-mode login.

  • Step 6: Full suite, once. go test -p 2 -count=1 ./..., pnpm -C web test --run, pnpm -C web lint, pnpm -C web typecheck, make generate && git status --short (clean), the lane-7 e2e. Fix anything red; do not re-run the full suite after each fix — re-run the touched packages, then the full suite once more at the end.

  • Step 7: Commit. MyInvois Task 12: e2e on lane 7, fake in dev-stack, roadmap, deploy notes


  • Spec coverage. D1 → Tasks 1–3, 8 (interfaces, pack, registration). D2 → Task 3 (client, onbehalfof, IG). D3 → Tasks 3, 6 (signer, worker-only key). D4 → Tasks 4, 8, 10. D5 → Tasks 1, 2, 4. D6 → Task 4. D7 → Task 4 (the “one row” test). D8, D9 → Task 6. D10 → Tasks 4, 5, 8. D11 → Tasks 5, 8 (void, retry). D12 → Tasks 4, 7. D13 → Task 7, 11. D14 → Task 2. D15 → Task 9. D16 → Task 5. D17 → nothing built, by design. D18 → Task 8. D19 → Tasks 3, 6, 8. D20 → Task 3 (fake, sandbox test), Task 12 (e2e). D21 → Tasks 4, 6. D22 → Tasks 10, 11.
  • Type consistency. einvoice.Submission field names are used by Tasks 6, 8, 9 as defined in Task 4; Client’s method set (Task 1) is what Task 3 implements and Task 6 calls; the four guard kinds are asserted by the cmd wiring tests in Tasks 6–8.
  • Known seams. receivables.TransitionDocument’s exact void branch, AR’s outstanding helper and AR’s test world names come from AR’s plan; if AR’s implementation named them differently, follow the code, not this plan. WHT and payment batches edit internal/platform/payables payment files; Task 7 edits invoice and supplier files only.