Skip to content

MyInvois submission and self-billed e-Invoices (Phase 2): design

Status: decided under the user’s “complete phase 2” goal (decisions D1–D22 below); the compliance-sensitive choices are listed at the end for the owner to confirm. Roadmap: Phase 2, “MyInvois: submission, API validation, status polling, retry” and “Self-billed e-Invoice on the AP side” — one spec, one plan. Architecture: §11 (the country pack owns LHDN; nothing outside platform/countrypack names it in the ledger), §11A.6 (issuing is Phase 2), §5 (workflow semantics, version pinning), §10 (append-only ledger), §4.6 (seller identifiers on the company profile). Builds on: the AR design (2026-09-27-accounts-receivable-design.md, D1–D20, its “Hand-off to MyInvois”) and the merged AP module (internal/platform/payables, AP spec D7). AR must be merged into phase-2 before this plan’s Task 4 starts (see “Dependencies”). Migrations: 00380–00399 (this item uses 00380–00382). E2E lane 7: ERP_STACK=erp-l7, pg 56482, minio 59170, api 3170, vite 5198, fake MyInvois 3177.

What LHDN’s API is, as far as this design relies on it

Section titled “What LHDN’s API is, as far as this design relies on it”

Read from the public MyInvois SDK (sdk.myinvois.hasil.gov.my) on 2026-09-27. Items marked [unverified] are our reading or inference and must be checked in the sandbox before go-live (DEPLOY_NOTES checklist, D21). Nothing here has been exercised against the real API.

Concern What the SDK says
Hosts API api.myinvois.hasil.gov.my (sandbox preprod-api.myinvois.hasil.gov.my); portal myinvois.hasil.gov.my (sandbox preprod.myinvois.hasil.gov.my). The identity service is documented as separate from the API service; in practice it is served on the API host [unverified].
Login POST /connect/token, form client_id, client_secret, grant_type=client_credentials, scope=InvoicingAPI; returns access_token, expires_in (3600 s). An intermediary adds header onbehalfof: <TIN> or <TIN>:<ROB>. Recommended ≤ 12 logins / minute / client.
Submit POST /api/v1.0/documentsubmissions/, body {"documents":[{"format":"JSON","document":<base64>,"documentHash":<sha256>,"codeNumber":<our number>}]}. ≤ 100 documents, ≤ 5 MB, ≤ 300 KB each. 202 → submissionUid, acceptedDocuments[{uuid, invoiceCodeNumber}], rejectedDocuments[{invoiceCodeNumber, error}]. 422 DuplicateSubmission for an identical submission within 10 minutes; 400 BadStructure, 400 MaximumSizeExceeded, 403 IncorrectSubmitter. documentHash encoding is hex [unverified].
Get submission GET /api/v1.0/documentsubmissions/{uid}?pageNo&pageSize → overallStatus (in progress, valid, partially valid, invalid), documentSummary[{uuid, longId, internalId, status (Submitted/Valid/Invalid/Cancelled), dateTimeValidated, …}]. Poll at 3–5 s intervals, 300 RPM.
Document details GET /api/v1.0/documents/{uuid}/details → status, longId, validationResults{status, validationSteps[{name, status, error}]}, cancelDateTime, rejectRequestDateTime, documentStatusReason. 125 RPM; “use only to retrieve error details”.
Recent documents GET /api/v1.0/documents/recent?submissionDateFrom&submissionDateTo&InvoiceDirection=Sent&pageNo&pageSize → uuid, submissionUID, longId, internalId, status. Last 31 days, 12 RPM, “for troubleshooting, not reconciliation”.
Cancel PUT /api/v1.0/documents/state/{uuid}/state {"status":"cancelled","reason":…} (≤ 300 chars), within 72 hours of the document being validated; 400 OperationPeriodOver, 400 IncorrectState, 400 ActiveReferencingDocuments, 403 Forbidden. 12 RPM. After 72 hours only a credit/debit note corrects it.
Issuance window The document’s issue date-time must be within 72 hours before submission (SDK FAQ).
Validation link {portal}/{uuid}/share/{longId} — what the QR code encodes.
Types 01 Invoice, 02 Credit Note, 03 Debit Note, 04 Refund Note, 11 Self-billed Invoice, 12 Self-billed Credit Note, 13, 14.
Codes 17 state codes (01 Johor … 14 WP Kuala Lumpur, 15 WP Labuan, 16 WP Putrajaya, 17 Not Applicable); 45 classification codes 001–045 (004 Consolidated, 022 Others, 034 Self-billed importation of goods, 035 importation of services, 036 Self-billed others, 037 payments to agents/dealers); tax types 01 sales, 02 service, 03 tourism, 04 high-value goods, 05 low-value goods, 06 not applicable, E exemption. General TINs: EI00000000010 general public, EI00000000020 foreign buyer, EI00000000030 foreign supplier, EI00000000040 government [the last three from secondary sources: unverified].
Signature Document version 1.1 carries a signature; version 1.0 may still be submitted unsigned “until LHDNM issues an official notice” (SDK FAQ). The certificate is X.509 from a Malaysian CA, RSA/SHA-256, with Non-Repudiation and Document Signing usages, issued to the taxpayer or to an intermediary acting for it. JSON signing: remove UBLExtensions and Signature, minify, SHA-256 → base64 (document digest), RSA-PKCS1 sign it, SHA-256 of the certificate → base64, build SignedProperties (SigningTime, CertDigest, IssuerSerial), digest them, insert UBLExtensions and Signature (ID urn:oasis:names:specification:ubl:signature:Invoice, method urn:oasis:names:specification:ubl:dsig:enveloped:xades), set InvoiceTypeCode.listVersionID to 1.1. The exact JSON shape of UBLExtensions [unverified — encoded from the SDK sample, sandbox-checked before go-live].

In:

  • Sales side: an organization-level “e-Invoice in scope” setting and the seller’s e-Invoice identity; stricter issue-time checks when in scope; every issued sales invoice and customer credit note of an in-scope organization queued for MyInvois in the issuing transaction; submission, validation status polling, retry with backoff, recovery of an unknown outcome; cancellation within LHDN’s window, and a way out of issued for a document LHDN refused (v2 of AR’s seeded graphs); the validated UUID, validation link and QR code on the invoice PDF; buyer rejection requests shown.
  • Purchase side: a purchase invoice or supplier credit note whose evidence is self-billed — the organization issues the e-Invoice on the supplier’s behalf (foreign suppliers, individuals and agents not in the scheme) — submitted as type 11/12 when it is approved, with the same polling, retry and cancellation.
  • A local fake MyInvois server (tests and e2e); an opt-in sandbox smoke test; a go-live checklist.

Out, each later or its own item:

  • Consolidated e-Invoices (monthly B2C, classification 004) — B2C buyers get an individual e-Invoice naming EI00000000010 in v1.
  • Debit and refund notes (types 03/04/13/14); AR and AP have none (AR D6). — superseded: see debit notes.
  • Inbound e-Invoices: fetching documents issued to the organization, and rejecting one as buyer. Claim lines and AP invoices keep the manually entered UUID (§6.7, AP D7).
  • Taxpayer-mode login and per-organization certificates (D2).
  • A PDF of a self-billed e-Invoice for the supplier; e-mailing anything (no mailer).
  • e-Invoicing customer receipts / advance payments (AR’s open question; D17).
  • Validation against the full MSIC and UN/ECE unit code lists (shape only; LHDN validates).
  • Tourism tax, high-value goods tax, low-value goods tax.
  • Notifications on failure (the submissions list is the surface in v1).

D1. A generic einvoice platform package; LHDN lives in the Malaysian country pack

Section titled “D1. A generic einvoice platform package; LHDN lives in the Malaysian country pack”
  • internal/platform/einvoice owns the tables, the neutral Document it builds from an AR or AP document, the issue-time guard, the submission state machine, the worker, and the rules for voiding. It never names LHDN or MyInvois.
  • internal/platform/countrypack/my/myinvois owns everything LHDN-specific: code tables, the in-scope checks that need them, the UBL 2.1 JSON encoder, the signer, the HTTP client and its error classification, and myinvoisfake (the fake server).
  • They meet at two interfaces in einvoice: Codec (pure: Check, Encode) and Client (network). Each has one implementation today; they exist because §11 puts the country boundary exactly there, and a second country is the only thing that would add a second implementation. The Codec is registered by country code from internal/modules/einvoice’s init() (pure code, linked into every binary that can transition); the Client is constructed from environment configuration in cmd/api and cmd/worker only.

D2. Login as an intermediary, credentials in the environment

Section titled “D2. Login as an intermediary, credentials in the environment”

v1 connects as one intermediary (the service provider): MYINVOIS_CLIENT_ID / MYINVOIS_CLIENT_SECRET held by the operator, and every call made onbehalfof the organization’s TIN (TIN:BRN for a TIN starting IG, a sole proprietor). Each organization authorizes the intermediary once in the MyInvois portal (a customer onboarding step, documented; the system cannot do it). Tokens are cached in process per onbehalfof value until 60 s before expires_in.

Why not taxpayer mode (each organization’s own client ID and secret): it needs per-organization secrets sealed at rest, a screen to enter and rotate them, and per-organization certificates (D3) — roughly a task and a half more, for a model the SDK says is equivalent. It is the obvious later addition if a customer refuses to appoint an intermediary.

D3. Signing: version 1.1 when a certificate is configured; the key never enters the database

Section titled “D3. Signing: version 1.1 when a certificate is configured; the key never enters the database”
  • The worker is the only process that signs (the API never submits). It reads MYINVOIS_SIGNING_CERT_FILE (PEM chain) and MYINVOIS_SIGNING_KEY_FILE (PKCS#8 PEM, RSA). Both set → documents are version 1.1, signed per the SDK procedure. Both empty → version 1.0, unsigned, which the SDK FAQ says LHDN still accepts until it gives notice. One set without the other → the worker refuses to boot.
  • Held as a file mounted into the worker container only (a Docker secret, mode 0400), outside the database and outside every database backup, like JWT_KEYS and PAYOUT_DETAILS_KEY are outside the database. It is not re-encrypted under an application key: the key that would open it would sit in the same container’s environment, so it would add a step without adding a barrier. The certificate is the intermediary’s (SDK: an intermediary may sign with its own certificate while it is the taxpayer’s active representative).
  • The worker logs the certificate’s subject and expiry at boot and daily, and warns within 30 days of expiry. No screen shows it in v1 (D19): the operator’s calendar holds the date (DEPLOY_NOTES).

D4. The “e-Invoice in scope” setting and the seller’s e-Invoice identity

Section titled “D4. The “e-Invoice in scope” setting and the seller’s e-Invoice identity”

einvoice_setting (one row per organization): in_scope (bool), in_scope_since, and the seller fields MyInvois needs that the company profile does not carry — business_activity (MSIC description), phone, email, structured address_line1/2, city, postcode, state_code (LHDN two-digit), country_code (ISO alpha-2) — plus revision.

The seller’s tin, registration_no (BRN), sst_registration_no and msic_code stay on company_profile (§4.6, where AR’s issue snapshot reads them), and the e-Invoice settings screen is where they are edited — no company-profile screen exists yet, and these four are the ones in-scope issuing needs. Saving goes through company.Update.

Turning in_scope on requires, and is refused (422, per field) without: seller TIN, BRN, MSIC code, business activity, phone, address line 1, city, postcode, state code, country; a company country whose Codec is registered (MY). Turning it off is allowed (documents already queued continue). Neither direction touches documents issued before the change: a document issued while out of scope is never submitted automatically — it may be past LHDN’s 72-hour window, and back-submitting is a decision for a person, out of v1. Both changes are audited. Only the Owner role holds einvoice.settings.manage by default (D18).

D5. Stricter issue-time checks when in scope, enforced in the transition itself

Section titled “D5. Stricter issue-time checks when in scope, enforced in the transition itself”

A new generic engine hook: workflow.RegisterGuard(kind, Guard); workflow.Transition calls the kind’s guard after authorization (and after a sign-off step completes), before the instance moves and before posting. einvoice registers guards for sales_invoice, customer_credit_note, purchase_invoice, supplier_credit_note from its init(). A guard may read and insert rows; it may not write audit_log (the Poster’s advisory lock must precede the audit chain lock — Plan 3’s caller constraint).

Why in the engine rather than in receivables.TransitionDocument: the four kinds live in two packages that einvoice must read, so they cannot import einvoice; one registration point in the engine keeps AR and AP ignorant of e-Invoicing, and a guard can never be skipped by a new code path that transitions the kind. AvailableTransitions does not consult guards (a refused move is a 422/409 with a reason).

For a sales document of an in-scope organization, on submit and issue (credit note) or issue (invoice), the Codec’s Check refuses (422 field=… rule=…, all problems at once):

  • buyer: name; TIN (shape; the general TINs are accepted); registration type and number; address line 1, city, postcode (MY buyers), a state that maps to a state code (17 for a non-MY buyer), country (ISO alpha-2 → alpha-3 table in the pack); phone. E-mail optional.
  • every line: classification code in the 45-code table; tax type derivable from tax_pack_kind (sales_tax→01, service_tax→02, exempt→E, out_of_scope→06); an exempt line has a tax_exemption_reason; unit code, when present, is 1–3 upper-case alphanumerics.
  • seller: the D4 fields (a concurrent settings change cannot slip past).
  • a credit note’s original (D12).

A buyer state is AR free text; the pack maps it (case-insensitive, English and Malay names, the codes themselves: “Penang” / “Pulau Pinang” / “07”). An unmapped state is refused with the accepted values, and the fix is on the customer record. Out of scope, nothing changes for AR.

D6. One submission row per document; what was declared is frozen

Section titled “D6. One submission row per document; what was declared is frozen”

einvoice_submission — one row per issued (or approved self-billed) document:

  • source: sales_document_id → sales_invoice or purchase_document_id → purchase_invoice (exactly one; composite FKs, no polymorphic id, AP Ruling P21’s shape), document_type (01, 02, 11, 12), code_number (our reference);
  • document_json — the unsigned UBL document, built by the Codec in the issuing transaction from AR’s frozen snapshot plus einvoice_setting at that moment, never rebuilt;
  • submitted_document — the exact bytes sent (signed when 1.1), document_hash, document_version, submission_uid, uuid, long_id, validation_url, validated_at, cancelled_at, reject_requested_at, status_reason, errors (JSONB: LHDN’s refusal, verbatim);
  • status ∈ queued | submitting | submitted | valid | invalid | cancelled | failed (strings), with failure_code when failed (window_expired, rejected_structure, forbidden, too_large, duplicate_unresolved, not_configured);
  • attempts, next_check_at (NULL once nothing more is expected), issued_at (copied from the source, for the 72-hour window), created_at, updated_at, revision.

Frozen by trigger: source, type, code number, document_json, issued_at never change; submitted_document, document_hash are write-once. Amendable (no DELETE grant), seven years: the UUID and the declared document are the statutory link between our record and LHDN’s.

D7. Queued in the issuing transaction; the event is not needed

Section titled “D7. Queued in the issuing transaction; the event is not needed”

The guard (D5) inserts the queued row with next_check_at = now() in the transaction that issues the document. So “issued and in scope ⇒ a submission row exists” holds by construction, which is stronger than the event-plus-sweep AR’s hand-off anticipated: a crash between commit and dispatch loses nothing, because nothing is dispatched. receivables.EventDocumentIssued stays unused. A test asserts the condition over every issued document of an in-scope organization after a mixed sequence.

D8. The worker: a 15-second sweep, one queue, submission then polling

Section titled “D8. The worker: a 15-second sweep, one queue, submission then polling”
  • River periodic job einvoice_sweep (every 15 s, queue einvoice, one worker so runs never overlap). A background job has no organization, and forced RLS hides every row from it, so it calls einvoice_due_orgs(), a SECURITY DEFINER function returning only the ids of organizations with a row whose next_check_at <= now() — no row content crosses the tenant boundary. It joins the allowlist in securitydefiner_test.go (the notification_prune precedent).
  • Per due organization, in db.InTx under that organization:
    1. Submit: claim up to 100 queued rows due (FOR UPDATE SKIP LOCKED), in one LHDN submission ≤ 5 MB. Sign each (D3), mark it submitting with the exact bytes and hash, commit, then call LHDN, then record the result in a second transaction. An accepted document becomes submitted with its uuid and submission_uid; a rejected one invalid with the error.
    2. Poll: every submitted row due is polled by submission (GET documentsubmissions/{uid}, one call per submission uid, paged): Valid → valid, validated_at, long_id, validation_url; Invalid → invalid plus GET documents/{uuid}/details once for the validation errors. Still in progress → next check in 5 s, backing off to 5 minutes. The first poll runs in the same sweep 3 s after submitting, so a quick validation is seen in one sweep.
    3. Watch the cancellation window: a valid row is re-read (documents/{uuid}/details) every 15 minutes until 72 hours after validated_at, to see a buyer’s rejection request (reject_requested_at, status_reason) or a cancellation made in the portal (→ cancelled, status_reason “cancelled at the authority”). Then next_check_at is NULL.
  • Every status change is audited (audit.Write, NULL actor — the worker honestly has none).

D9. Errors: transient retries with backoff; permanent failures stop and say why

Section titled “D9. Errors: transient retries with backoff; permanent failures stop and say why”
Outcome Classification Row becomes
network error, timeout, 5xx, 429 (honouring Retry-After), 401 (token dropped and re-fetched) transient back to queued (or stays submitted when polling), attempts+1, next_check_at = now + min(30 s × 2^attempts, 30 min)
transient, but the next attempt would fall after issued_at + 72 h − 15 min window failed, window_expired
400 BadStructure, MaximumSizeExceeded; a document over 300 KB permanent failed, rejected_structure / too_large
403 IncorrectSubmitter / login refused (intermediary not authorized for this TIN) permanent failed, forbidden
a document in rejectedDocuments the document itself invalid, errors stored
422 DuplicateSubmission, or a row found submitting by a later sweep (the process died between sending and recording) unknown outcome recover (below)
Client not configured in the worker configuration stays queued; after the window, failed, not_configured

Unknown outcome recovery. Before resending, the worker asks documents/recent (submission dates from the row’s first attempt to now, direction Sent) for a document whose internalId equals the row’s code_number. Found → adopt its uuid / submission_uid / status. Not found and older than 10 minutes → resend. Not found and younger → wait. Found twice (a second copy accepted) → failed, duplicate_unresolved, for a person to cancel the extra in the portal. This is the one use of an endpoint LHDN says is for troubleshooting, and it runs only on this path.

Manual retry: POST /einvoice/submissions/{id}/retry puts a failed row back to queued if the 72-hour window has not closed (409 window_closed otherwise) — for after a person fixed the cause (authorized the intermediary, fixed the worker’s configuration). It never rebuilds document_json.

D10. Cancellation: v2 of AR’s graphs, one guarded exit, LHDN first

Section titled “D10. Cancellation: v2 of AR’s graphs, one guarded exit, LHDN first”

AR’s seeded graphs, v2 (owned here, changed in internal/platform/receivables/graphs.go): issued becomes non-terminal on both sales_invoice and customer_credit_note, with one exit issued → voided (“Cancelled”, semantic cancelled, terminal), gate target, the OWNER seat granted, reason required, ReversesAccrual. The number is kept (the series stays gapless; the voided number shows as cancelled with its reason).

When the void is allowed is the einvoice guard’s decision, by the document’s submission:

Submission status Sales documents Self-billed purchase documents
none (issued out of scope) refused, 409 correct_by_credit_note — AR’s rule (correction only by credit note) stands AP’s own void rules, unchanged
queued, submitting, submitted refused, 409 einvoice_in_progress same
valid refused, 409 einvoice_cancel_first — use Cancel e-Invoice same
cancelled, invalid, failed allowed allowed

A purchase invoice that is not self-billed has no row and its void is untouched. AR’s TransitionDocument gains AP’s rule for this exit: refused while the document has allocations (receipts or credit notes applied to it; for a credit note, its own self-allocation) — reverse them first (AP P24’s two steps).

Cancel e-Invoice (POST /einvoice/submissions/{id}/cancel, {reason}), for a valid row within 72 hours of validated_at:

  1. Rehearse: in a transaction that is rolled back, mark the row cancelled and run the document’s own void transition (receivables.TransitionDocument / payables.TransitionInvoice) as the caller. This proves every refusal the real void could raise — the caller’s grant, allocations, a closed period — before LHDN is touched. Nothing persists (numbering, journal, audit all roll back).
  2. Call LHDN’s cancel. OperationPeriodOver → 409 window_closed (use a credit note); ActiveReferencingDocuments → 409 has_credit_notes; IncorrectState → re-read the document at LHDN and continue if it is already cancelled.
  3. Commit the same two steps for real, then audit.

If step 3 fails after LHDN cancelled, the row is still valid in our database while LHDN says cancelled; the D8 window watch finds it within 15 minutes, marks it cancelled, and the ordinary void then succeeds. The screen tells the user to retry the void.

Version pinning. Instances pin their definition version (§5.2): a document issued under AR’s v1 graph keeps a terminal issued forever. The provisioning step (D16) moves unedited organizations to v2 before any in-scope document exists in practice; a v1 document is corrected by credit note.

D11. Invalid and failed documents are voided and reissued, never resubmitted edited

Section titled “D11. Invalid and failed documents are voided and reissued, never resubmitted edited”

An invalid document’s frozen data is what LHDN refused (usually a buyer identifier). The record is not edited: it is voided (D10, allowed without an LHDN call — nothing valid exists there) and a new document is issued with the corrected customer, under a new number. The voided number and its reason stay in the series. A failed document may be retried (D9) while the window is open, else voided and reissued the same way.

D12. Credit notes reference their original

Section titled “D12. Credit notes reference their original”

A customer credit note of an in-scope organization carries BillingReference to its original invoice: the original’s code_number and uuid. The guard requires the original’s submission to be valid; if it is queued/submitting/submitted → 409 original_einvoice_pending (wait a minute and retry); invalid/cancelled/failed → 409 original_einvoice_not_valid (a voided original needs no credit note). An original issued before the organization was in scope has no row; the reference then carries the original number and the UUID NA [unverified: LHDN’s rule for crediting a pre-mandate invoice]. The same rule applies to self-billed credit notes (D13).

D13. Self-billed e-Invoices on the AP side

Section titled “D13. Self-billed e-Invoices on the AP side”

When. A purchase invoice or supplier credit note whose document_basis is the new value self_billed: the supplier cannot issue an e-Invoice (a foreign supplier, an individual not in business, an agent or dealer receiving commission — LHDN’s self-billed cases), so the organization issues it. It is submitted when the document reaches its accrual state (AP’s “Posted”, after approval): the guard queues type 11 (invoice) or 12 (credit note) in that transaction. LHDN’s deadline for importation of services is the end of the month after payment or receipt of the supplier’s invoice, whichever is first; approval normally falls well inside it [not verified for every self-billed category].

Only in scope. self_billed requires einvoice_setting.in_scope (422 self_billed_needs_scope at save): an organization outside the scheme has no obligation and no means to submit.

Data. AP already stores the supplier’s tin, registration_no, country_code, structured address, email, phone on party (AP D1), and AR adds party.registration_type (AR D1). This item:

  • adds self_billed to purchase_invoice.document_basis (named CHECK rebuilt), with supplier_tin and einvoice_uuid required NULL for it — the UUID is LHDN’s answer and lives on the submission row, not on the frozen purchase invoice;
  • adds purchase_invoice_line.classification_code (three digits; required on every line of a self-billed document at submit and approval; the web defaults it to 036, “Self-billed – Others”);
  • adds purchase_invoice.original_invoice_id (a supplier credit note’s original; required when the credit note is self-billed, and must be an approved self-billed invoice of the same supplier) — the freeze guard’s column list gains it;
  • exposes registration_type on the supplier API and form.

Checks (Codec, at submit and approval): supplier name, TIN (a foreign supplier without one uses EI00000000030; shape-checked), registration type and number (a foreign supplier with neither sends NA [unverified]), country, address line 1 and city, a state code (17 outside Malaysia), phone; buyer = the organization (the D4 seller fields in the buyer role); every line’s classification code. The supplier’s MSIC is sent as 00000 and SST as NA [unverified].

Lines. AP lines carry a net amount only: each is sent as quantity 1, unit price = net, tax type from the line’s tax code’s pack_kind (E/06 for AP’s no-SST code), tax amount as entered.

Void. AP’s approved → voided edge is unchanged; the guard applies D10’s table. After 72 hours a wrong self-billed invoice is corrected by a self-billed credit note.

D14. The UBL document and money on LHDN’s wire

Section titled “D14. The UBL document and money on LHDN’s wire”

The Codec encodes UBL 2.1 JSON (_D, _A, _B namespace keys; every value an array of {"_": value, …attributes}): header (ID, IssueDate/IssueTime from issued_at in UTC, InvoiceTypeCode with listVersionID, DocumentCurrencyCode, TaxCurrencyCode MYR, TaxExchangeRate when foreign), AccountingSupplierParty / AccountingCustomerParty (TIN, BRN / NRIC / PASSPORT / ARMY, SST, TTX NA; MSIC with business activity; address with state code and alpha-3 country; legal name; phone; e-mail), BillingReference (credit notes), lines (InvoicedQuantity with unitCode, LineExtensionAmount, discount as AllowanceCharge, TaxTotal with one TaxSubtotal: taxable amount, tax amount, percent, TaxCategory tax type and exemption reason, TaxScheme OTH; Item with CommodityClassification CLASS and description; Price; ItemPriceExtension), document TaxTotal grouped by tax type, LegalMonetaryTotal.

Money. LHDN’s JSON carries amounts as JSON numbers. Our wire rule (a string) is for our own API; here the encoder writes each money.Amount’s decimal text verbatim as a json.Number — the value never passes through float64. internal/platform/countrypack/my/myinvois joins the no-float scan. Amounts are to the sen (AR D4 already guarantees it on sales lines; AP’s are 4-decimal columns holding sen). The document is built by encoding/json in compact form, so the bytes we hash and sign are the bytes we send; there is no separate “minify” step to drift from.

A golden test compares the encoder’s output for a fixed invoice, credit note and self-billed invoice with files in testdata/, written from the SDK’s published samples. Matching our golden file proves only that we produce what we believe LHDN wants; the sandbox is the proof (D21).

D15. The invoice PDF carries the validation

Section titled “D15. The invoice PDF carries the validation”

AR’s GET /receivables/invoices/{id}/pdf (AR D15) adds, once the submission is valid: “e-Invoice UUID”, “Validated at” (Malaysia time), “Validation link”, and a QR code of the link in the page’s top right. Before validation an in-scope document’s PDF says “e-Invoice: pending validation” and has no QR; a cancelled one says “e-Invoice: cancelled”. Out of scope, the PDF is AR’s commercial copy.

render.Meta gains QR string; the PDF writer draws it on page 1 when set (CSV/XLSX ignore it). The QR is encoded with github.com/boombuler/barcode/qr, already in the module graph (an indirect dependency of pquerna/otp), promoted to direct — no new dependency.

D16. Existing organizations move to v2 through a provisioning step

Section titled “D16. Existing organizations move to v2 through a provisioning step”

receivables registers step 78 “sales graphs v2” (after workflow step 60; WHT claims 77): for each of sales_invoice, customer_credit_note, if the latest published version is version 1 published by the system (published_by IS NULL — the seed, never edited) and has no edge leaving issued, it publishes the current seeded graph as version 2. An organization that edited the flow is left alone and readiness warns “add the Cancel exit to the sales invoice flow”. New organizations get v2 directly from SeedDefaults. Idempotent (its Check is “no unedited v1 remains”).

Customer receipts (including money on account, AR D9), supplier payments, petty cash, claims. Whether an advance payment must itself be e-Invoiced on receipt is unresolved (AR asked); v1 does not.

einvoice.read (submissions list and detail, settings view), einvoice.submission.manage (retry, cancel — cancel additionally needs the document’s own void grant, proven by the rehearsal), einvoice.settings.manage (the in-scope switch and seller identity). Seeded: Owner all three; Finance einvoice.read and einvoice.submission.manage. New organizations via provisioning.RegisterRolePermissions; existing roles via migration 00382 (the 00305 pattern). einvoice.settings.manage is Owner-only by default because turning it on starts a statutory obligation for every later document.

Variable Where Meaning
MYINVOIS_API_URL api, worker Empty = submission disabled. https on a hasil.gov.my host, or http://127.0.0.1:<port> / http://localhost:<port> for the fake (logged as a warning at boot, shown on the settings screen).
MYINVOIS_IDENTITY_URL api, worker Token host; same rule.
MYINVOIS_PORTAL_URL api, worker Validation link host (the worker stores each link); same rule; required with the API URL.
MYINVOIS_CLIENT_ID, MYINVOIS_CLIENT_SECRET api, worker The intermediary’s credentials (D2). Required when the API URL is set; the secret placeholder is refused at boot.
MYINVOIS_SIGNING_CERT_FILE, MYINVOIS_SIGNING_KEY_FILE worker only D3. Both or neither.
MYINVOIS_SANDBOX_CLIENT_ID, MYINVOIS_SANDBOX_CLIENT_SECRET, MYINVOIS_SANDBOX_TIN developer machine Read only by the opt-in sandbox smoke test (build tag myinvois_sandbox). Never read by a binary.

The API needs the client only for cancellation; the worker for everything. The settings screen shows whether the API’s client is configured (authority_configured).

No table records the worker’s configuration: the certificate expiry is a boot and daily log line from the worker, and the DEPLOY_NOTES checklist puts the date in the operator’s calendar.

myinvoisfake is an http.Handler implementing the six endpoints above with an injectable clock and per-codeNumber scripts: accept then validate; accept then invalidate with errors; reject at submit; fail N times with 500/429; accept and drop the response (unknown outcome); duplicate within 10 minutes; cancel within/after 72 hours; a buyer rejection request. It verifies documentHash against the decoded document and, for version 1.1, the RSA signature against the certificate embedded in the document — so the signer is tested end to end. Go tests use httptest.NewServer(myinvoisfake.New(…)); the e2e runs cmd/myinvois-fake. The sandbox smoke test (go test -tags myinvois_sandbox ./internal/platform/countrypack/my/myinvois/...) submits one invoice, polls it and cancels it; CI never runs it.

einvoice_submission and einvoice_setting are amendable (no DELETE). The sandbox checklist that must pass before go-live is in docs/DEPLOY_NOTES.md (one submission of each type 01, 02, 11, 12, one invalid, one cancellation, one signed 1.1 document, the QR link opening in the portal). The guard writes no audit row (D5); the queue insert is covered by the transition’s own audit (workflow_instance, and the document’s), and the row carries the transition time. Every later change — by the worker or a person — writes audit_log last.

  • Accounting → e-Invoices (one nav item appended to the Accounting group): tab Submissions (DataTable einvoice.submissions: number, type, counterparty, status chip, validated at, UUID, failure; filter by status; row → the source document’s drawer) and tab Settings (the D4 form, in-scope switch with a confirmation that names the obligation).
  • e-Invoice panel in the AR sales document drawer and the AP purchase invoice drawer: status, UUID, validated at, validation link (copy), LHDN errors, buyer rejection request, and the actions Retry and Cancel e-Invoice (reason dialog) when available.
  • AP invoice form: “Self-billed e-Invoice” as a document basis; a classification code select per line when self-billed; the original invoice select on a self-billed credit note. Supplier form: registration type.
  • en / ms / zh; genuine Malay.
  • AR (hard). Consumes: receivables.GetDocument, Document and its snapshot fields, TransitionDocument, KindInvoice, KindCreditNote, GetCustomer, graphs.go’s helpers, the module’s pdf.go (renderDocument) and errors.go (mapErr), party.registration_type. Changes: the two graphs to v2, one allocation check on the void, step 78, the PDF fields, error mapping. EventDocumentIssued is not consumed (D7).
  • AP (merged). Changes purchase_invoice (00381), payables invoice/line/supplier code, the AP module’s handlers and web form.
  • WHT and payment batches edit internal/platform/payables payment paths; this item edits the invoice and supplier paths only. Merge after them or rebase; no shared functions.
  • SST-02 is unaffected: it reads AR’s lines, not submissions.
  • Guard: a registered guard’s refusal aborts the move with no decision row (presence: without the guard the move succeeds); a partial sign-off does not run it.
  • In-scope checks: each missing buyer/seller field refused by name (presence: the complete document issues); out of scope the same incomplete document issues.
  • Every issued in-scope document has exactly one submission row after a mixed sequence; an out-of-scope one has none; turning scope on later queues nothing retroactively.
  • Encoder golden files; no float64 in the pack; amounts round-trip as decimal text.
  • Signer: a 1.1 document verifies at the fake (presence: a corrupted digest fails there).
  • Worker against the fake: valid path; invalid with errors; rejected at submit; 500×3 then success with backoff times asserted; 429 Retry-After; window expiry → failed; unknown outcome adopted (found) and resent (not found, > 10 min); duplicate found twice → duplicate_unresolved; buyer rejection request recorded; portal cancellation noticed.
  • einvoice_due_orgs() returns only ids and only due ones (presence: a due row’s org appears); allowlisted SECURITY DEFINER.
  • Void table (D10) cell by cell; cancel within 72 h (LHDN cancelled, document voided, entry reversed); after 72 h 409 with nothing changed; rehearsal refusal (no grant) leaves LHDN untouched (the fake records no cancel call).
  • Credit note original rules (D12).
  • Self-billed: basis requires scope; classification required; type 11/12 queued at approval, not at submit; UUID never written to purchase_invoice.
  • Provisioning step: unedited v1 → v2; edited → untouched with a warning; idempotent.
  • PDF: a valid document’s PDF carries the UUID and a QR image; a pending one says so.
  • Config: every variable in .env.example; bad hosts refused; cert without key refused.
  • e2e (lane 7): settings in scope → customer → invoice issued → panel shows Valid with UUID (fake) → PDF → Cancel e-Invoice → document Cancelled, e-Invoice Cancelled.
  1. Intermediary login only (one provider credential, each customer authorizes us in the portal). Alternative: taxpayer login with each organization’s own client ID/secret — about one more task, per-organization secret storage and rotation. Confirmed (2026-09-29): standard for a multi-tenant ERP; taxpayer login comes when a customer asks.
  2. Signing key as a file on the worker only, not re-encrypted, never in the database; unsigned version 1.0 when no certificate is configured. Alternative: sealed in the database under an application key (the payout-details pattern) or an HSM/KMS — more moving parts; per-organization certificates would require it. Risk: LHDN ends 1.0 acceptance and an unconfigured deployment stops validating. Needs outside authority: confirm in the LHDN sandbox and SDK FAQ that v1.0 is still accepted and until when, and obtain the provider’s digital certificate (MCMC-licensed CA) before go-live. Until then, keep the boot warning and the DEPLOY_NOTES sandbox checklist item.
  3. Documents issued before “in scope” was switched on are never submitted automatically. Alternative: a back-submission tool for documents still inside the 72-hour window. Confirmed (2026-09-29): as designed.
  4. Invalid or failed documents are voided and reissued under a new number (the old number stays in the series as cancelled). Unsure whether LHDN expects the same internal number to be reused after an invalid submission. Confirmed (2026-09-29): keeps the internal series immutable; LHDN holds no record of an invalid document.
  5. Cancellation is a void of the issued document (reversing its entry) and needs the OWNER seat and a reason; after 72 hours only a credit note. Alternative: let Finance cancel. Confirmed (2026-09-29): as designed.
  6. Self-billed e-Invoices are submitted at approval (posting) of the purchase invoice. Unsure whether every self-billed category’s timing (e.g. imported goods after customs clearance) is met by that; alternative: submit at payment. Confirmed (2026-09-29): earliest submission cannot be late, and O-2 (payment blocked until valid) depends on it.
  7. A self-billed foreign supplier with no TIN uses EI00000000030, MSIC 00000, SST and missing IDs NA — from secondary sources, not the SDK itself. Needs outside authority: validate one self-billed foreign-supplier document in the LHDN sandbox (DEPLOY_NOTES checklist item). Until then keep the defaults.
  8. B2C sales get individual e-Invoices naming the general-public TIN; no consolidated monthly e-Invoice in v1. Confirmed (2026-09-29): individual e-Invoices are always compliant; consolidation is optional relief, deferred.
  9. A credit note for a pre-mandate invoice references it by number with UUID NA — unverified. Needs outside authority: sandbox-validate one such credit note. Until then it stays as coded.
  10. Customer receipts and advance payments are not e-Invoiced. Needs outside authority: the LHDN e-Invoice Specific Guideline appears to require an e-Invoice for an advance payment received; get the tax agent to confirm against the current guideline version (tracked as EINV-10, shared with AR-5). Meanwhile, show a warning on a receipt that leaves money on account in an in-scope org.
  11. The in-scope switch is Owner-only by default. Confirmed (2026-09-29): as designed.
  12. Cancellation reason and void reason are the same text, stored in English or Malay (record language) and sent to LHDN. Confirmed (2026-09-29): as designed.
  13. A local fake is accepted as MYINVOIS_API_URL on loopback hosts only, with a boot warning — a production misconfiguration would “validate” against nothing. Alternative: no loopback exception, and no e2e of the validated path. Confirmed (2026-09-29): as designed.

Also needing an outside authority: B2C/foreign buyers on the general TINs (EI00000000010/…20): send NA for a missing ID and phone, or keep requiring one, as today (ROADMAP review I4). Sandbox- validate a B2C document with ID and phone NA and check LHDN guideline Appendix 1; if accepted, relax the requirement (PDPA data minimisation favours it). Keep requiring until then.

Corrections found while implementing Tasks 6–8; the decisions above (D9–D11) are left as written, each amended here rather than rewritten:

  • **D9’s error table lists duplicate_unresolved/not_configured as failure_code values but not outcome_unknown (Task 6’s manual-retry recovery, D9’s own “unknown outcome” row). M2’s list of failure_code values gains outcome_unknown alongside the others.
  • A manual retry attempted after the 72-hour window has closed is not_retryable (implemented in internal/platform/einvoice/manual.go), not the window_closed code D9’s prose implies — that code is Cancel e-Invoice’s own (D10), for a different refusal (cancelling, not retrying, past the window). The two codes read alike but are for two different endpoints.
  • not_configured (no MyInvois client in this worker/API process) is a 503, apperr.KindUnavailable (einvoice_not_configured) — a client absence is a deployment gap, not a client error about the request.
  • D10’s issued → voided exit (OWNER position, all_states: false) can block a wholly unrelated transition in a single-Owner organization. workflow.AvailableTransitions runs workflow.Route for every draft-exiting edge against every GateTarget state Graph.ApprovalStates() returns for the whole definition — not only states reachable from the edge under evaluation. So when the Owner is also the invoice’s reporter, Route’s reporter-exclusion rule (a claimant never counts as their own approver) empties the voided grant’s eligible set, and the unrelated “Issue invoice” (gate: reporter, no approval needed at all) is refused not_routable as collateral damage. Found running Task 12’s e2e as a single actor; worked around there by having a second person (Finance) report the invoice while the Owner still cancels. Same class of limitation as AP’s “Single-Owner orgs cannot approve their own payment” (ROADMAP.md); a real fix belongs in internal/platform/workflow, shared by every module, and is out of this plan’s scope. Fixed in the final review (Ruling EINV-F2): Graph.ApprovalStates leaves out a target-gated edge that reverses what was posted (reverses_accrual into a rejected or cancelled state), so Route no longer routes a submission through the void. Ruling 13’s “every approval step” guarantee is kept for every forward step and every pre-posting rejection; Authorize still gates the void when it is taken. What remains: the Owner cannot void an invoice they themselves prepared (the claimant rule on the void grant).