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.
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00380–00399only. This plan uses00380–00382. The range is already declared ininternal/kernel/db/migrationranges_test.go({380, 399, "Phase 2 MyInvois and self-billed e-Invoice"}) — do not edit that file. - Money never float.
money.Amountin 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 asjson.Number, never throughfloat64(spec D14).internal/platform/einvoice,internal/platform/countrypack/my/myinvois,internal/modules/einvoiceandweb/src/features/einvoicejoin the no-float scans (internal/platform/reporting/render/nofloat_test.go’s pattern for Go;web/src/drift/nofloat.test.tsfor the web). Compare withCmp, never==. - Every new table has
org_id UUID NOT NULL REFERENCES organization (id),ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid, a tenant-idUNIQUE (org_id, id)(ororg_idas the primary key, thecompany_profileshape), and composite(org_id, x)foreign keys only. - Named constraints. Every
CHECK/UNIQUE/FOREIGN KEYisCONSTRAINT <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 oneSECURITY DEFINERfunction,einvoice_due_orgs(), is added tosecurityDefinersininternal/kernel/db/securitydefiner_test.gowith its reason,REVOKE ALL … FROM PUBLIC,GRANT EXECUTE … TO erp_app, and refuses to run unless its owner bypasses RLS (the00271shape). - Every SQL statement in Go names
org_id = $nfor each org-scoped table it touches, joins included; everyINSERTnamesorg_id. Platform functions read the org fromtenancy.OrgFromContext. - Retention.
internal/kernel/db/retention_test.go:einvoice_setting,einvoice_submissionareamendable(REVOKE DELETE). - Accounting reacts to semantics. Guards decide by
posting.SemanticofMove.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’saudit.Write, last. A guard never callsaudit.Write(it runs before the Poster’s advisory lock). In the worker: submission rowsFOR 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 passdb.InTx’s error throughledger.MapErrorbeforemapErr. - Business date is
clock.TodayUTC(c); timestamps fromc.Now(); nevertime.Now()in code under test. The fake takes aclock.Clocktoo. - Record-bearing text (seller business activity and address, cancellation reason) is English or Bahasa Malaysia:
recordtext.Checkin Go and a named scriptCHECK (… !~ '[-ヿ㐀-䶿一-鿿가-]')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 withcode). The AR and AP modules’mapErrmap them (Task 8). - Every route goes through
httpx.Protectedwith aneinvoice.*permission and is inapi/openapi.yamlin the same commit.api/public_routes.txtgains nothing.make generateafter every migration, OpenAPI orconstants.yamlchange; generated files committed;git statusclean 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.exampletoo. - Tests never reach LHDN. Go tests use
httptest.NewServer(myinvoisfake.New(...)); the e2e usescmd/myinvois-fake. The only test that talks to LHDN is behind//go:build myinvois_sandboxand 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.tsrequires identical keys and placeholders. Malay is genuine Malay — the strings in this plan are the ones to use. Seeded workflow labels carryen,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), nevert.Parallel(). The same applies to the Codec/Client registries: tests that register useeinvoicetest.Use(t, codec, client)which restores on cleanup, nevert.Parallel(). - Tests:
testdb.Startper test (through the existing test worlds); run only touched packages while iterating; one fullgo 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 inscripts/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>
Rulings
Section titled “Rulings”Plan-level choices the spec leaves open. Format: Ruling: <decision> — <why>.
- M1 Ruling: Package names —
einvoice(platform and module),myinvois(the pack’s sub-packageinternal/platform/countrypack/my/myinvois),myinvoisfake,einvoicetest; tableseinvoice_setting,einvoice_submission; audit entity kinds are the table names — one vocabulary. - M2 Ruling: Submission statuses
queued, submitting, submitted, valid, invalid, cancelled, failed; failure codeswindow_expired, rejected_structure, forbidden, too_large, duplicate_unresolved, not_configured; document types01, 02, 11, 12— strings, a named CHECK each, Go constants ineinvoice. - M3 Ruling:
workflow.RegisterGuard(kind, Guard)panics on a bad key, a nil guard or a second registration (theRegisterSeededGraphrules);workflow.HasGuard(kind)exists for the cmd wiring tests. The guard runs inTransitionafter the sign-off early return (a partial sign-off never runs it) and before the instance moves. - M4 Ruling:
einvoiceregisters its four guards frominit()ininternal/platform/einvoice/guard.go;internal/modules/einvoice/boot.goregisters the MY Codec. Every binary that links a module that transitions (cmd/{api,migrate,orgadmin,worker}) linksinternal/modules/einvoice, and eachwiring_test.goassertsworkflow.HasGuardfor the four kinds andeinvoice.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_notestill applies — a sales void needs a submission row), and for a purchase document with basisself_billedrefuses 422self_billed_needs_scope. - M6 Ruling: The guard builds the neutral
Documentfromreceivables.GetDocument(issued: the snapshot; not yet issued, i.e. a credit note’s submit: overlaid withreceivables.GetCustomerandcompany.Get) orpayables.GetInvoice+payables.GetSupplier+company.Get, pluseinvoice.GetSetting. - M7 Ruling: Sweep period 15 s (
river.PeriodicInterval(15*time.Second),RunOnStart), queuejobs.QueueEInvoice = "einvoice"withMaxWorkers: 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 atissued_at + 72h − 15m; a next attempt past it makes the rowfailed/window_expired. Polling asubmittedrow: 5 s, 10 s, 20 s … capped at 5 m; the first poll 3 s after submitting, inside the same sweep (time.Sleepvia an injectablePause func(time.Duration), a no-op in tests). - M9 Ruling: The window watch re-reads a
validrow every 15 minutes untilvalidated_at + 72h, then setsnext_check_atNULL. Acancelled/invalid/failedrow hasnext_check_atNULL. - 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
onbehalfofvalue; 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’sPI-…/CN-…reference); LHDN’sinternalIdis matched against it for unknown-outcome recovery. - M13 Ruling: Cancel’s rehearsal is
db.InTxreturning the sentinelerrRehearsalafter 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/submissionsis keyset-paginated on(created_at DESC, id DESC)(AP’scursor.go, copied), filtersstatus,source_id; limit ≤ 100. - M15 Ruling: A state name (
buyer_address_state, supplieraddress_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 to17. - M16 Ruling: Classification default on the web — AR lines keep AR’s
022; AP self-billed lines default to036. - 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.Fieldskeep their existing layout.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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. |
New Go packages
Section titled “New Go packages”| 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. |
Modified (shared)
Section titled “Modified (shared)”| 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 inTransition) - 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(packageworkflow_test) on the existingworkflowtestworld and its test kind (mirrorinternal/platform/workflow/instance_test.go’s setup: publish a graphdraft → 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.goand 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.goandregistry.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:TestAGuardRefusalAbortsTheMovestill green (the tx rolls back) — so also move it above the sign-off block:TestAPartialSignoffDoesNotRunTheGuardred (1 != 0calls). Record, restore. Delete the call:TestAGuardRefusalAbortsTheMovered (errnil). 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, implementseinvoice.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.gobuilds a completeeinvoice.Document(helpercomplete()) 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 carryEI00000000030,NAregistration; the buyer is the organization). Rules codes:required,tin_shape(thetaxrecordregex, copied as a literal with a comment namingtaxrecord.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.goandubl.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]anyfunc 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— copyinternal/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
numreturnjson.Number(strconv.FormatFloat(a.Float64(), 'f', -1, 64))— ifAmounthas noFloat64, writefloat64anywhere innumber.go:nofloat_testred namingnumber.go. (b) Drop theexemptreason rule:TestACompleteInvoicePassesAndEachGapIsNamedred onlines[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.ServerwithScript(codeNumber string, s Script),Calls() []Call,ServeHTTP; thecmd/myinvois-fakebinary (-addr). -
Step 1: Failing config tests.
myinvois_test.go(mirrorbnm_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:httpsand hosthasil.gov.myor*.hasil.gov.my; orhttpwith host127.0.0.1/localhostand an explicit port (Loopback = true). API URL set ⇒ identity URL, portal URL, client ID and secret required. Secret placeholder refused (reuse the placeholder check inplaceholders_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.gogenerates an RSA-2048 key and a self-signed certificate in the test (x509.CreateCertificate), writes them tot.TempDir(), encodestestdata/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.godrives the fake with a rawhttp.Client(nomyinvois.Client), one test per behaviour the spec’s D20 lists: token; submit accepted thenSubmissionshowsValidwith alongId;ScriptInvalidshowsInvalidand details carryvalidationResults;ScriptRejectAtSubmitreturns it inrejectedDocuments;ScriptFail(3, 500)fails three times then accepts;ScriptThrottlereturns 429 withRetry-After: 7;ScriptDropResponseaccepts then returns 502 (the document is inrecent); a byte-identical resubmission within 10 minutes is422 DuplicateSubmission, after 10 minutes accepted as a new uuid; cancel before 72 h after validation succeeds, after returnsOperationPeriodOver;ScriptRejectRequestsetsrejectRequestDateTimeon details; a wrongdocumentHashis400 BadStructure; a version-1.1 document with a bad signature is invalid with a signature validation step error; a request withoutonbehalfofor with a token for another TIN is403. -
Step 6:
myinvoisfake/fake.go. Onehttp.ServeMux, state in memory under a mutex, clock injected. Documents are keyed by uuid (26 upper-case alphanumerics,crypto/rand); long ids likewise. It checksdocumentHash == hex(sha256(decoded))(the client’sHashmust agree — [unverified] hex vs base64; the fake enforces hex, matching the spec’s reading). It exportsVerifySignature(doc []byte) error(the inverse ofSign: stripUBLExtensions/Signature, resetlistVersionIDto1.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.goagainsthttptest.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.Submitbuilds the body{"documents":[{"format":"JSON","document":base64,"documentHash":Hash,"codeNumber":…}]}.errors.goholds the oneclassify(resp *http.Response, body []byte, err error) errorswitch — the spec’s D9 table and the Step 7 list, nothing else decides retryability.NewClientreturns an error whencfg.APIURLis 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.examplelists every variable; the sandbox file is excluded by its tag). -
Step 11: Sabotage. (a) Remove
MYINVOIS_SIGNING_KEY_FILEfrom.env.example:TestEveryEnvVarReadAppearsInEnvExamplered naming it. (b) Inclassify, map 429 toPermanentError: the client test red at the 429 case. (c) InSign, hashdocinstead of the version-1.1body:TestASignedDocumentVerifiesred. 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,Documentfields;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 rulevoidAllowed;einvoicetest.Env(onreceivablestest.Env) withInScope(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 ofdocument_jsonrefused by name; ofstatusaccepted;submitted_documentsettable 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 StatementBeginCREATE 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 StatementEndCREATE 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 StatementBeginCREATE 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 StatementEndREVOKE ALL ON FUNCTION einvoice_due_orgs() FROM PUBLIC;GRANT EXECUTE ON FUNCTION einvoice_due_orgs() TO erp_app;
-- +goose DownDROP 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.GetSettingreturns the zeroSetting(out of scope) when no row;UpdateSettingupserts withrevisionoptimistic check (RevisionMismatchError, AR’s shape), writes the four company fields throughcompany.Get→ modify →company.Update, and whenin_scopeturns on runs the D4 list against the would-be values plusCodecFor(profile.CountryCode)present — refusing with*CheckError.in_scope_sinceset on the off→on edge, cleared on on→off.audit.Writelast (entityeinvoice_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, oneinvoicetest.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.
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
salesGuardbeforequeue:TestEveryIssuedInScopeDocumentHasOneSubmissionred. (b) Comment out theRAISEineinvoice_submission_frozen:schema_testred at thedocument_jsonUPDATE. (c) Removeeinvoice_due_orgsfromsecurityDefiners:TestEverySecurityDefinerIsAllowlistedAndNotPublicred 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,seathelpers;workflow.Latest,PublishGraph,provisioning.Register,provisioning.Warning;ownerSeatOfis private to workflow — useorgstructthe wayworkflow.ownerSeatOfdoes, or exportworkflow.OwnerSeat(ctx, tx)(a one-line exported wrapper; preferred — do not copy the selection rule). -
Produces: v2
InvoiceGraph,CreditNoteGraph;receivables.StepOrderSalesGraphsV2 = 78;receivables.ErrHasAllocationson the void (reuse AR’s sentinel if Task 6 of AR defined one, else add it). -
Step 1: Replace AR’s
TestAnIssuedInvoiceHasNoExitwith:
// 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, …); addst(6, "voided", posting.Cancelled, true, ls("Cancelled", "已作废", "Dibatalkan")); addwhy(ed(6, "issued", "voided", workflow.GateTarget, "void", ls("Void", "作废", "Batalkan")), true); addseat("voided", ownerSeat)toGrants. Update the ASCII diagrams in the doc comments. -
Step 4: The void’s allocation rule. In
TransitionDocument, forfrom.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), elseErrHasAllocations. Setcancelled_atas the withdraw path does. Test (invoid_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 throughreceivables.TransitionDocumenton the real edge, one subtest per status (set with a test helpere.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
ReversesAccrualfrom the void edge:workflow.ValidateGraphreportsreversal_missingandTestAnIssuedDocumentsOnlyExitIsTheReversingVoidred. (b) Inupgradable, drop thePublishedBytest: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(kindeinvoice_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, oneinvoicetest.Env, fake clock advanced by hand;RunOrgcalled 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:
- 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 asubmittingrow (a previous pass died mid-call, or recorded an unknown outcome) → collect for recovery, not resend. For aqueuedrow:cl == nil→ if past the windowfailed/not_configuredelse leave (next check +5 m); past the window →failed/window_expired; else ifsubmitted_documentis NULL →cl.Sign(document_json, c.Now()), store bytes, hash, version,first_sent_at; →submitting,attempts+1. Commit. - no tx: recovery rows →
cl.Recent(tp, first_sent_at−1m, now), matchInternalID == code_number(M12): one match → adopt (submitted, uuid, submission uid); two →failed/duplicate_unresolved; none andfirst_sent_atolder than 10 minutes → move to the resend batch; none and younger → leavesubmitting, next check +5 m. - no tx:
cl.Submit(tp, batch)(batch ≤ 5 MB; a single document > 300 KB isfailed/too_largewithout a call). - tx: record — accepted →
submitted, uuid, uid,next_check_at = now(polled in this pass); rejected →invalid, errors, next NULL;TransientError→ back toqueued,next_check_at = now + backoff(attempts, RetryAfter)(M8), orfailed/window_expiredwhen past the window;PermanentError→failed/<code>;UnknownOutcomeError→ staysubmitting, next +5 m. Every change throughsetStatus(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); whenmcfg.APIURL != "":signer, err := myinvois.LoadSigner(mcfg.CertFile, mcfg.KeyFile)(fatal on error; log subject andNotAfter, warn within 30 days, and re-log daily from the prune loop’s ticker),client, err := myinvois.NewClient(…),einvoice.UseClient(client); log a warning whenmcfg.Loopback. The sweep is registered whether or not a client is configured (queued rows must still reachfailed/not_configuredwhen the window closes).startExportWorkergainsriver.AddWorker(workers, &einvoice.SweepWorker{DB: database, Clock: clock.System(), Logger: cfg.Logger, Pause: time.Sleep})and the caller addsjobs.QueueEInvoice: 1andeinvoice.SweepPeriodic(). Linkinternal/modules/einvoice(Task 8 creates it; until then linkinternal/platform/einvoiceblank and add the module import in Task 8).wiring_test.go:workflow.HasGuardtrue forsales_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 NULLtest): the trigger refuses the second write —TestTransientFailuresBackOffThenSucceedred witheinvoice_submission_frozen. (b) Resend asubmittingrow without recovery:TestAnUnknownOutcomeIsRecoveredNotDuplicatedred (two documents at the fake). (c) Make the submit call inside the claim transaction:TestNoNetworkCallHappensInsideATransactionred. 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.goadditions
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_basisenum +self_billed, lineclassification_code, invoiceoriginal_invoice_id, supplierregistration_type. -
Step 1: Failing schema test —
self_billedaccepted bypurchase_invoice_basis_known(presence) and aself_billedrow witheinvoice_uuidset refused bypurchase_invoice_self_billed_no_identifiers; a credit note withself_billedand no original refused bypurchase_invoice_self_billed_credit_note_original;classification_code '36'refused bypurchase_invoice_line_classification_shape;original_invoice_idon an approved document refused bypurchase_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 DownDROP 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_billedhandled beforetaxrecord.Normalize(which does not know it): saved withsupplier_tin/einvoice_uuidsupplied → 422self_billed_identifiers(“a self-billed e-Invoice gets its identifier from LHDN; leave these empty”); a credit note withself_billedneedsoriginal_invoice_idnaming an approved self-billed invoice of the same supplier (422original_invoiceotherwise; presence: a correct one saves);classification_codeshape-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. Supplierregistration_type∈brn nric passport army(422 otherwise) and needsregistration_no(AR’s rule onparty). -
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;checkSubmissionadds the classification rule.supplier.go:RegistrationTyperead/write onparty(the column AR added). No change totransition.go— the guard is in the engine. -
Step 5: Failing guard tests (
selfbilled_test.go, oneinvoicetest.Env+payablestesthelpers: the env embeds both worlds, or build a small AP helper on the sameexpensestest.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()registerspurchaseGuardforpayables.KindInvoice,payables.KindCreditNote. Void:voidAllowed(ForPurchase(...), false). Otherwise: load the invoice; notself_billed→ nil; out of scope →FieldError{"document_basis", "self_billed_needs_scope", …}wrapped inCheckError; build the Document (Supplier = the party’s fields,MSIC "00000", SST/registrationNAwhen empty per D13; Buyer = company + setting; lines quantity 1, unit price = net (money.RateFromAmountor equivalent — checkinternal/kernel/moneyfor the existing conversion and use it), tax type from the line’s tax code’spack_kindviachart.GetTaxCode;IssuedAt = c.Now(),CodeNumber = Reference);Check; onTo.Semantic == posting.Accrual→originalReadyfor credit notes,Encode,queue. -
Step 7: API.
api/openapi.yaml: AP schemas gain the four fields (enumself_billedon everydocument_basisenum 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.goassertworkflow.HasGuardforpurchase_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:
TestASelfBilledInvoiceIsQueuedAtApprovalNotAtSubmitred. (b) Removeoriginal_invoice_idfrom 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; permissionseinvoice.read,einvoice.submission.manage,einvoice.settings.manage. -
Step 1: Permissions and boot.
permissions.gomirrorsinternal/modules/payables/permissions.go: threemember.RegisterPermissioncalls (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 ofinternal/platform/einvoice(for its guards).constants.yamlgains the three keys.00382mirrors00305(Owner: three keys; Finance: two; bumpperm_version; Down deleteseinvoice.%). -
Step 2: OpenAPI (first, then
make generate): schemasEInvoiceSettings(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}.409responses carrycode(window_closed,has_credit_notes,einvoice_not_configured,not_cancellable,not_retryable,revision_mismatch).If-Matchon the settings PUT. -
Step 3: Failing handler tests (
submission_handlers_test.go, mirrorinternal/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’sLoopback.GET /einvoice/classification-codesreturns the pack’s 45 (read through aCodes()method onmyinvois.Codec, or a package function; used by both web forms). -
Step 6: Error mapping.
internal/modules/einvoice/errors.goand both AR’s and AP’smapErrgain, before their default:*einvoice.CheckError→ 422 (joined problems);*einvoice.FieldError→ 422;*einvoice.ConflictError→ 409With("code", e.Code);*einvoice.AuthorityRefusal→ 409window_closed/has_credit_notes;*einvoice.TransientError→ 503 “LHDN did not answer; try again”;einvoice.ErrNotConfigured→ 503einvoice_not_configured. A test per module: an in-scope issue with a gap returns 422 withfield=buyer.tin. -
Step 7: Composition.
internal/app/app.goregisters the module’s routes (mirror the payables module’s registration);routes_test.goexpectations.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.goblank-importinternal/modules/einvoice;wiring_test.goassertseinvoice.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:
TestAMemberWithoutTheVoidGrantIsRefusedBeforeLHDNIsCalledred (fake count 1). (b) Register/einvoice/settingsPUT witheinvoice.read:TestTheOpenAPIDocumentSaysWhatTheRouterEnforces/TestSettingsNeedTheSettingsPermissionred. (c) Remove the blank import fromcmd/orgadmin/main.go: itswiring_testred. 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: aMeta{QR: "https://preprod.myinvois.hasil.gov.my/X/share/Y"}PDF contains an image XObject (/Subtype /Image) and one withoutQRdoes 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 tidymust move it to the direct block and change nothing else (checkgit diff go.mod). No new module. -
Step 3: Draw it. In
pdfWriter.newPage, page 1 only, whenp.meta.QR != "":qr.Encode(p.meta.QR, qr.M, qr.Auto)→barcode.Scale(code, 288, 288)→png.Encodeto 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 ignoreQR. 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, afterreceivables.GetDocument:s, err := einvoice.ForSales(ctx, tx, d.ID);renderDocument(d, s)appends, whens != 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}andMeta.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.QRonly when the status issubmitted:TestAValidatedInvoicesPDFCarriesItsUUIDLinkAndQRred. 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 byweb/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 409window_closedsentence; 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; aauthority_is_fakebanner shows. -
Step 2: Build it. Nav: one item appended to the
accountinggroup (/accounting/einvoice, iconReceiptText, permissioneinvoice.read), per the nav’s append-only rule.EInvoicePage=TabsSubmissions | Settings (Settings visible witheinvoice.settings.manage, read-only witheinvoice.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 follow2026-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/einvoiceandpnpm -C web test --run src/lib/i18n(parity) andpnpm -C web lint && pnpm -C web typecheck. -
Step 5: Sabotage. Delete
status.submittingfromms/einvoice.json:i18nparity.test.tsred 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 fromuseClassificationCodes, default036), 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":
# 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-secretfiand 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 withERP_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):
- As the Owner: Accounting → e-Invoices → Settings; fill TIN
C20880050010, BRN, MSIC62010, business activity, phone, address (Selangor → state10), country MY; switch in scope on, confirm; the fake banner is visible. - Create a customer with TIN, BRN, address in “Selangor”, phone.
- Create a sales invoice for them (one line,
ST10, classification022), issue it. - 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.
- Download the PDF (200,
application/pdf). - 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.tsis 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 signingMYINVOIS_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.pemandopenssl 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
onbehalfofa sandbox TIN; (2) submit and validate one of each type01,02,11,12; (3) one deliberately invalid document shows LHDN’s errors; (4) one signed version1.1document validates (signature shape,documentHashhex); (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 UUIDNAis 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_URLat a loopback address in production (the fake validates everything).
- Migrations
-
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
Self-Review
Section titled “Self-Review”- 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.Submissionfield 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 editinternal/platform/payablespayment files; Task 7 edits invoice and supplier files only.