"CRM vietnam-vat-invoice skill — Decree 123 hóa đơn auto-emit on deal.stage=won + invoice issuance + verification code retrieval"
§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.
- MUST register skill
vietnam-vat-invoice@1per DEC-1700.
- MUST hook into deal stage transitions at
services/crm/src/deals.rs— onwonANDtenant.residency='vn-1'ANDaccount.vn_account_type IS NOT NULL, invoke skill async.
- MUST validate
vat_invoice_triggeragainst closed enum per DEC-1701.
- MUST orchestrate at
invoice_orchestrator.rs::orchestrate(deal, trigger):
- Check if existing emission row (idempotent per DEC-1702).
- Call TASK-INV-001 create invoice (account_id, contact_id, line items from deal).
- Call TASK-INV-007 hóa đơn emit (delegation per DEC-1703).
- Store emission record with status.
- 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;``
- 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}``
- MUST emit 4 memory audit kinds per DEC-1704. PII per TASK-MEMORY-111: deal_value SHA-256 hashed; ids ok.
- MUST thread trace_id from deal-hook / manual call → orchestrator → TASK-INV-001 → TASK-INV-007 → audit.
- MUST be silent skip on non-VN tenant per DEC-1700 — no emission row created.
- MUST NOT duplicate emit per deal per DEC-1702 — UNIQUE constraint enforces.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Non-VN tenant | residency check | silent skip | inherent |
| vn_account_type missing | early validate | sev-3; skip | CRO fills field |
| TASK-INV-001 create fails | downstream err | status=failed; sev-2 | CFO investigates |
| TASK-INV-007 emit fails | downstream err | status=failed; sev-2 | CFO retry |
| Duplicate won race | UNIQUE constraint | second skipped | inherent |
| Stage revert won→neg | no auto-cancel | CFO uses TASK-INV-008 manually | by design |
| Deal value 0 | proceed (Decree 123 OK) | inherent | inherent |
| GDT acceptance pending | status=emit_delegated | poll via TASK-INV-007 | inherent |
| Manual retry on accepted | UNIQUE rejects | 409 | inherent |
| Non-CFO manual call | role check | 403 | request CFO |
| Cross-tenant lookup | RLS | 404 | inherent |
§11 — Implementation notes
- §11.1 Orchestrator first creates invoice (TASK-INV-001), then calls TASK-INV-007 emit; status reflects which step we're at.
- §11.2 Invoice line items derived from deal.proposed_items (CRM stores proposal line items per deal).
- §11.3 memory audit body: deal_id, trigger, invoice_id, hoadon_id; amounts SHA256.
- §11.4 Manual retry path: looks up existing invoice_id, calls TASK-INV-007 emit (which is itself idempotent).
- §11.5 No auto-cancel on stage revert — CFO must explicitly use TASK-INV-008 cancellation flow (Decree 123 Art. 19 requirements).
End of TASK-CRM-010 spec.