Task — engineering-spec@1

"INV VN hóa đơn auto-emit on AM-send — Decree 123/2020 GDT XML signing + idempotent transmission + verification code retrieval for VN tenants"

draftTASK-INV-007
module inv · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-INV-001 · blocks TASK-INV-008, TASK-CRM-010

§1 — Description (BCP-14 normative)

The INV service MUST ship VN hóa đơn auto-emit at services/invoicing/src/hoadon/ triggered on first AM-send for VN tenants, XML-signed per Decree 123, transmitted to GDT via async task with verification-code polling, 5 memory audit kinds.

  1. MUST hook into services/invoicing/src/handlers/invoice_send.rs — on first send (AC: send_count → 1), if tenant.residency='vn-1' AND invoice.currency='VND', enqueue hóa đơn emit task per DEC-1520. Non-VN tenants: skip silently.
  1. MUST build XML at xml_builder.rs::build(invoice) per Decree 123 Annex 1 schema — root <HDon> with <DLHDon> (invoice data), <DSHHDVu> (line items), <TToan> (totals), <TTHDon> (transaction info). UTF-8, canonical form.
  1. MUST sign XML at signer.rs::sign(xml, tenant_id) — load tenant's signing cert from KMS via tenant.signing_cert_kms_arn; XMLDSig per W3C spec embedded in <Signature> element. Sign canonical form per DEC-1521. KMS errors → fail emit (don't transmit unsigned).
  1. MUST transmit at gdt_client.rs::submit(signed_xml, tenant) to tenant.gdt_environment URL — production https://hoadondientu.gdt.gov.vn or sandbox. Receive immediate ack with hoadon_id. Verification code arrives async (5min-2hr per DEC-1524).
  1. MUST poll at poller.rs::poll(hoadon_id, tenant) via TASK-MCP-007 task — every 5min for first hour, every 15min thereafter, max 24h. On verification_code receipt: update row to accepted, emit inv.hoadon_verification_received. On 24h timeout: status=pending (CFO investigates).
  1. MUST define vn_hoadon table at migration 0007: ``sql CREATE TABLE vn_hoadon ( hoadon_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, invoice_id UUID NOT NULL UNIQUE, hoadon_status TEXT NOT NULL DEFAULT 'pending' CHECK (hoadon_status IN ('pending','signed','transmitted','accepted','rejected','cancelled')), gdt_invoice_number TEXT, verification_code TEXT, xml_signed BYTEA, gdt_response JSONB, submitted_at TIMESTAMPTZ, verified_at TIMESTAMPTZ, failure_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX vn_hoadon_invoice_idx ON vn_hoadon(invoice_id); ALTER TABLE vn_hoadon ENABLE ROW LEVEL SECURITY; CREATE POLICY vn_hoadon_rls ON vn_hoadon USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON vn_hoadon FROM cyberos_app; GRANT UPDATE (hoadon_status, gdt_invoice_number, verification_code, gdt_response, submitted_at, verified_at, failure_reason, updated_at) ON vn_hoadon TO cyberos_app; ``
  1. MUST be idempotent per DEC-1522 — UNIQUE(invoice_id) constraint + ON CONFLICT DO NOTHING; duplicate emit attempt returns existing row.
  1. MUST emit 5 memory audit kinds per DEC-1526. PII scrub per TASK-MEMORY-111 — invoice amounts SHA-256 hashed, only hoadon_id + status in chain.
  1. MUST thread trace_id from AM-send through emit + sign + transmit + poll.
  1. MUST NOT transmit unsigned XML per DEC-1521.
  1. MUST NOT emit for non-VN tenants per DEC-1520 — silent skip is the contract.

§2 — Why this design

Why auto-emit on send (DEC-1520)? Decree 123 requires hóa đơn at point-of-sale; "send" is the legal trigger. Manual emit doubles workflow.

Why idempotent (DEC-1522)? Retry storms must not produce duplicate hóa đơn — each duplicate is a legal violation requiring cancellation paperwork.

Why async polling (DEC-1524)? GDT verification code latency is 5min-2hr; sync would timeout. Polling is documented GDT-recommended pattern.

Why KMS-backed signing (DEC-1521)? Signing cert is tenant-specific GDT-issued; must not be checked into code or env vars. KMS provides audit trail per HSM compliance.


§3 — API contract

Endpoints (internal):

POST   /v1/inv/hoadon/emit            (called by invoice_send handler)
GET    /v1/inv/hoadon/{invoice_id}    (status poll for CFO/UI)
POST   /v1/inv/hoadon/{id}/resubmit   (CFO-only, after rejection fix)

Sample XML (Decree 123 schema, abbreviated):

<HDon xmlns="http://kekhaithue.gdt.gov.vn/TKhaiHDon">
  <DLHDon>
    <TTChung>
      <PBan>2.0.0</PBan>
      <THDon>1</THDon>
      <KHMSHDon>1</KHMSHDon>
      <KHHDon>K24TAA</KHHDon>
      <SHDon>00000001</SHDon>
      <NLap>2026-05-17</NLap>
      <DVTTe>VND</DVTTe>
    </TTChung>
    <NDHDon>
      <NBan><Ten>{tenant_name}</Ten><MST>{tenant_tax_id}</MST></NBan>
      <NMua><Ten>{customer_name}</Ten></NMua>
      <DSHHDVu><HHDVu>...</HHDVu></DSHHDVu>
      <TToan><THTTLTSuat>...</THTTLTSuat><TgTCThue>1000000</TgTCThue></TToan>
    </NDHDon>
  </DLHDon>
  <DSCKS><NBan><Signature>...</Signature></NBan></DSCKS>
</HDon>

§4 — Acceptance criteria

  1. Auto-emit on first send (VN). 2. No emit on non-VN tenants. 3. No emit on USD/SGD/EUR invoices. 4. XML schema valid per Decree 123. 5. Signed via tenant KMS cert. 6. Idempotent (duplicate request = same hoadon_id). 7. Status enum 6 values. 8. GDT transmission via TASK-MCP-007 task. 9. Verification code polled async. 10. Failure → status=rejected + CFO notification. 11. 5 memory audit kinds emitted. 12. PII scrubbed (amounts → SHA-256). 13. RLS denies cross-tenant view. 14. KMS errors fail emit (not transmit). 15. Retry on transient GDT 5xx. 16. Trace_id preserved. 17. Resubmit endpoint CFO-only. 18. 24h poll timeout → pending+investigation. 19. GDT environment switchable per-tenant. 20. No duplicate UNIQUE invoice constraint. 21. Append-only (UPDATE on status only, no row delete). 22. Hoadon_id stable across resubmits.

§5 — Verification

#[tokio::test]
async fn auto_emits_on_first_vn_send() {
    let ctx = TestContext::vn_tenant_with_signed_invoice().await;
    ctx.send_invoice(ctx.invoice_id).await;
    let row: VnHoadonRow = ctx.fetch_hoadon(ctx.invoice_id).await;
    assert_eq!(row.hoadon_status, "transmitted");
    assert!(row.xml_signed.is_some());
}

#[tokio::test]
async fn skips_non_vn_tenant() {
    let ctx = TestContext::sg_tenant_with_invoice().await;
    ctx.send_invoice(ctx.invoice_id).await;
    let row = ctx.try_fetch_hoadon(ctx.invoice_id).await;
    assert!(row.is_none());
}

#[tokio::test]
async fn idempotent_duplicate_emit() {
    let ctx = TestContext::vn_tenant_with_signed_invoice().await;
    let h1 = ctx.emit_hoadon(ctx.invoice_id).await;
    let h2 = ctx.emit_hoadon(ctx.invoice_id).await;
    assert_eq!(h1, h2);
}

// 5.4..5.10 — signing, retry, audit, RLS, resubmit, enum cardinality, PII scrub

§6 — Skeleton

// services/invoicing/src/hoadon/mod.rs
pub async fn emit(invoice_id: Uuid, tenant: &Tenant, db: &Db) -> Result<HoadonId> {
    if tenant.residency != "vn-1" { return Err(Skip::NonVnTenant.into()); }
    let invoice = db.fetch_invoice(invoice_id).await?;
    if invoice.currency != "VND" { return Err(Skip::NonVndInvoice.into()); }
    let existing = db.try_get_hoadon(invoice_id).await?;
    if let Some(h) = existing { return Ok(h.hoadon_id); }
    let trace = current_span_trace_id();
    audit::emit("inv.hoadon_emit_started", json!({"invoice_id": invoice_id}), trace).await?;
    let xml = xml_builder::build(&invoice, tenant)?;
    let signed = signer::sign(&xml, tenant).await?;
    audit::emit("inv.hoadon_signed", json!({"invoice_id": invoice_id}), trace).await?;
    let row = db.insert_hoadon(invoice_id, &signed, tenant).await?;
    queue_transmission(row.hoadon_id, signed, tenant.clone()).await?;
    Ok(row.hoadon_id)
}

§7 — Dependencies

Upstream: TASK-INV-001. Cross-module: TASK-MCP-007 (async task), TASK-AUTH-101 (KMS), TASK-MEMORY-111 (PII scrub), TASK-INV-008 (cancellation flow). Tenant config: TASK-SKILL-109 placeholder (signing_cert_kms_arn admin UI — created on first VN tenant signup).

§8 — Sample payloads

GDT acceptance response:

{
  "hoadon_id": "uuid",
  "gdt_invoice_number": "K24TAA-00000001",
  "verification_code": "ABCD1234EFGH",
  "verified_at": "2026-05-17T15:00:00Z",
  "status": "accepted"
}

§9 — Open questions

None blocking — all per Decree 123 + GDT API docs.

§10 — Failure modes

FailureDetectionOutcomeRecovery
KMS cert missingKMS 404status=pending; sev-2 auditCFO uploads cert
KMS sign errorsign() failstatus=pending; not transmittedretry after KMS fix
GDT 5xxclient errorretry 3x w/ backoffexponential 2/4/8min
GDT 4xx (XML invalid)response codestatus=rejected; sev-2CFO fixes invoice → resubmit
Verification timeout 24hpoller expirystatus=pending; sev-1CFO investigates GDT portal
Duplicate submit attemptUNIQUE constraintreturns existinginherent
Non-VN tenant callearly returnsilent skipinherent
Cert expiredKMS signalfail emit; sev-1CFO renews cert
Network partition mid-submitclient timeoutpoll on reconnectinherent (idempotent)
GDT environment misconfigwrong URLrejected by sandbox/prodCFO fixes tenant config
Verification code already usedGDT 409mark cancelled+reissueCFO escalation
Tenant tax_id missingXML build failstatus=pending; sev-2onboarding fix
Audit chain pausememory unavailableretry emitper TASK-MEMORY-111

§11 — Implementation notes


End of TASK-INV-007 spec.