Task — engineering-spec@1

"CRM vietnam-vat-invoice skill — Decree 123 hóa đơn auto-emit on deal.stage=won + invoice issuance + verification code retrieval"

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

§1 — Description (BCP-14 normative)

The CRM service MUST ship vietnam-vat-invoice@1 skill at services/crm/src/vn/vat_invoice_skill.rs triggered on deal.stage=won for VN tenants, creating invoice via TASK-INV-001 + delegating emit to TASK-INV-007, idempotent, 4 memory audit kinds.

  1. MUST register skill vietnam-vat-invoice@1 per DEC-1700.
  1. MUST hook into deal stage transitions at services/crm/src/deals.rs — on won AND tenant.residency='vn-1' AND account.vn_account_type IS NOT NULL, invoke skill async.
  1. MUST validate vat_invoice_trigger against closed enum per DEC-1701.
  1. MUST orchestrate at invoice_orchestrator.rs::orchestrate(deal, trigger):
  1. MUST define table at migration 0010: ``sql CREATE TABLE crm_vat_invoice_emissions ( emission_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, deal_id UUID NOT NULL UNIQUE, -- idempotent per DEC-1702 invoice_id UUID, hoadon_id UUID, trigger TEXT NOT NULL CHECK (trigger IN ('deal_won_auto','manual_emit','retry_on_failure')), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','invoice_created','emit_delegated','accepted','failed')), failure_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE crm_vat_invoice_emissions ENABLE ROW LEVEL SECURITY; CREATE POLICY emissions_rls ON crm_vat_invoice_emissions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON crm_vat_invoice_emissions FROM cyberos_app; GRANT UPDATE (invoice_id, hoadon_id, status, failure_reason, updated_at) ON crm_vat_invoice_emissions TO cyberos_app; ``
  1. MUST expose skill endpoint: ``text POST /v1/crm/skill/vietnam-vat-invoice body: {deal_id, trigger: 'manual_emit'|'retry_on_failure'} GET /v1/crm/skill/vietnam-vat-invoice/emissions/{deal_id} ``
  1. MUST emit 4 memory audit kinds per DEC-1704. PII per TASK-MEMORY-111: deal_value SHA-256 hashed; ids ok.
  1. MUST thread trace_id from deal-hook / manual call → orchestrator → TASK-INV-001 → TASK-INV-007 → audit.
  1. MUST be silent skip on non-VN tenant per DEC-1700 — no emission row created.
  1. MUST NOT duplicate emit per deal per DEC-1702 — UNIQUE constraint enforces.
  1. MUST NOT reimplement TASK-INV-007 logic per DEC-1703 — orchestration only.

§2 — Why this design

Why orchestration (DEC-1703)? TASK-INV-007 is canonical hóa đơn emit; CRM-010 wires the trigger. Duplication = drift.

Why idempotent (DEC-1702)? Deal stage may transition won→re-won (e.g. correction); we must not create two hóa đơn.

Why deal.stage=won trigger (DEC-1700)? Decree 123 requires hóa đơn at point of legal commitment; won deal = commitment.

Why optional manual trigger (DEC-1701)? Edge cases: deal won pre-system, CFO retroactively creates; manual path needed.


§3 — API contract

POST   /v1/crm/skill/vietnam-vat-invoice       (manual call)
GET    /v1/crm/skill/vietnam-vat-invoice/emissions/{deal_id}

Sample manual request:

{
  "deal_id": "uuid",
  "trigger": "manual_emit"
}

Sample emission status:

{
  "emission_id": "uuid",
  "deal_id": "uuid",
  "invoice_id": "uuid",
  "hoadon_id": "uuid",
  "status": "accepted",
  "trigger": "deal_won_auto",
  "gdt_verification_code": "ABCD1234EFGH"
}

§4 — Acceptance criteria

  1. Auto-trigger on stage=won for VN tenant. 2. Non-VN tenant → silent skip. 3. Account missing vn_account_type → skip + sev-3 audit. 4. 3-trigger enum + cardinality test. 5. Idempotent (UNIQUE deal_id). 6. TASK-INV-001 invoice created with deal context. 7. TASK-INV-007 emit delegated. 8. 4 memory audit kinds emitted. 9. PII scrubbed (deal_value SHA256). 10. RLS denies cross-tenant. 11. Trace_id preserved. 12. Status transitions tracked (pending→invoice_created→emit_delegated→accepted). 13. TASK-INV-007 failure → status=failed; CFO sees. 14. Manual trigger CFO-only. 15. Append-only via REVOKE UPDATE except status cols. 16. GET endpoint returns emission status. 17. Stage revert won→negotiating → no auto-cancel hóa đơn (CFO uses TASK-INV-008). 18. Retry trigger re-invokes TASK-INV-007 (uses existing invoice_id). 19. Hoadon_id stable across retries. 20. Deal value 0 → still emits (Decree 123 allows zero-value).

§5 — Verification

#[tokio::test]
async fn auto_emits_on_vn_deal_won() {
    let ctx = TestContext::vn_tenant_with_deal_in_proposal().await;
    ctx.change_deal_stage(ctx.deal_id, "won").await;
    tokio::time::sleep(Duration::from_secs(2)).await;
    let emission = ctx.fetch_emission(ctx.deal_id).await.unwrap();
    assert!(emission.invoice_id.is_some());
    assert!(emission.hoadon_id.is_some());
}

#[tokio::test]
async fn idempotent_duplicate_won() {
    let ctx = TestContext::vn_tenant_with_deal().await;
    ctx.change_deal_stage(ctx.deal_id, "won").await;
    ctx.change_deal_stage(ctx.deal_id, "negotiating").await;
    ctx.change_deal_stage(ctx.deal_id, "won").await;  // re-won
    let emissions = ctx.fetch_all_emissions(ctx.deal_id).await;
    assert_eq!(emissions.len(), 1);
}

#[tokio::test]
async fn skips_non_vn_tenant() {
    let ctx = TestContext::sg_tenant_with_deal().await;
    ctx.change_deal_stage(ctx.deal_id, "won").await;
    let emission = ctx.fetch_emission(ctx.deal_id).await;
    assert!(emission.is_none());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-INV-007, TASK-CRM-004. Cross-module: TASK-INV-001 (invoice create), TASK-SKILL-109 (registry), TASK-AUTH-101 (CFO role for manual), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Non-VN tenantresidency checksilent skipinherent
vn_account_type missingearly validatesev-3; skipCRO fills field
TASK-INV-001 create failsdownstream errstatus=failed; sev-2CFO investigates
TASK-INV-007 emit failsdownstream errstatus=failed; sev-2CFO retry
Duplicate won raceUNIQUE constraintsecond skippedinherent
Stage revert won→negno auto-cancelCFO uses TASK-INV-008 manuallyby design
Deal value 0proceed (Decree 123 OK)inherentinherent
GDT acceptance pendingstatus=emit_delegatedpoll via TASK-INV-007inherent
Manual retry on acceptedUNIQUE rejects409inherent
Non-CFO manual callrole check403request CFO
Cross-tenant lookupRLS404inherent

§11 — Implementation notes


End of TASK-CRM-010 spec.