"CRM VN account types + MST — legal entity classification (Sole/LLC/JSC/FDI) + tax ID field with format validation"
§1 — Description (BCP-14 normative)
The CRM service MUST extend Account schema with VN-specific fields at services/crm/src/vn/ — legal entity type + MST + validation gated on residency, 3 memory audit kinds.
- MUST define table extension at migration
0003: ``sql ALTER TABLE crm_accounts ADD COLUMN vn_account_type TEXT CHECK (vn_account_type IS NULL OR vn_account_type IN ('sole','llc_1','llc_2plus','jsc','fdi','partnership')); ALTER TABLE crm_accounts ADD COLUMN mst TEXT; ALTER TABLE crm_accounts ADD COLUMN mst_validated_at TIMESTAMPTZ; ALTER TABLE crm_accounts ADD CONSTRAINT mst_format CHECK (mst IS NULL OR mst ~ '^[0-9]{10}(-[0-9]{3})?$'); CREATE INDEX accounts_vn_mst_idx ON crm_accounts(tenant_id, mst) WHERE mst IS NOT NULL; GRANT UPDATE (vn_account_type, mst, mst_validated_at) ON crm_accounts TO cyberos_app;``
- MUST validate
vn_account_typeagainst closed enum per DEC-1631.
- MUST validate MST format at
vn/mst_format.rs::validate(mst)per DEC-1632:
- 10 digits only OR 10 digits +
-+ 3 digits. - Reject letters, spaces, other separators.
- MUST require both fields when
account.residency='vn-1'per DEC-1633 — at INSERT/UPDATE, if residency=vn-1 and either NULL → reject 400.
- MUST allow both fields NULL for non-VN accounts.
- MUST emit 3 memory audit kinds per DEC-1634. Audit body: account_id, vn_account_type (enum); MST SHA-256 hashed per TASK-MEMORY-111 (treat as PII — could be confidential).
- MUST thread trace_id from account create/update → validation → audit.
- MUST NOT accept MST formats outside DEC-1632 — CHECK constraint enforces.
- MUST NOT require fields on non-VN accounts per DEC-1633.
§2 — Why this design
Why 6 entity types (DEC-1631)? Enterprise Law 59/2020 enumerates these as the legal forms; LLC is split into 1-owner vs 2+ because they have distinct registration/tax treatment.
Why MST format (DEC-1632)? GDT rejects malformed MST in hóa đơn; pre-validate at CRM level saves a downstream failure.
Why residency-gated (DEC-1633)? Non-VN accounts don't have MST; making it mandatory annoys global users.
§3 — API contract
Account fields (extension):
{
"account_id": "uuid",
"name": "Acme JSC",
"residency": "vn-1",
"vn_account_type": "jsc",
"mst": "0312345678"
}
Branch MST (13 digits):
{ "mst": "0312345678-001" }
§4 — Acceptance criteria
- 6 account types enum + cardinality test. 2. MST 10-digit accepted. 3. MST 13-digit (with dash) accepted. 4. MST 9/11/12/14 rejected (400 + CHECK). 5. MST with letters rejected. 6. MST optional for non-VN. 7. MST required for vn-1 residency. 8. vn_account_type optional for non-VN. 9. vn_account_type required for vn-1. 10. 3 memory audit kinds emitted. 11. PII scrubbed (MST SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Index on mst for lookup. 15. mst_validated_at populated on successful validation. 16. Append-only via REVOKE UPDATE except 3 cols. 17. CRO UI picker shows 6 types. 18. TASK-CRM-008 future validation skill leverages this format check. 19. TASK-INV-007 hóa đơn emit reads mst from this column. 20. Multi-line FDI/JSC company name OK in name field (not affected).
§5 — Verification
#[tokio::test]
async fn mst_10_digit_accepted() {
let ctx = TestContext::vn_residency_account().await;
let r = ctx.update_account_mst(ctx.account_id, "0312345678").await;
assert!(r.is_ok());
}
#[tokio::test]
async fn mst_13_digit_branch_accepted() {
let ctx = TestContext::vn_residency_account().await;
let r = ctx.update_account_mst(ctx.account_id, "0312345678-001").await;
assert!(r.is_ok());
}
#[tokio::test]
async fn mst_required_for_vn_residency() {
let ctx = TestContext::new_tenant().await;
let r = ctx.create_account_no_mst(ctx.tenant_id, "vn-1").await;
assert!(r.is_err());
}
#[tokio::test]
async fn mst_not_required_for_sg() {
let ctx = TestContext::new_tenant().await;
let r = ctx.create_account_no_mst(ctx.tenant_id, "sg-1").await;
assert!(r.is_ok());
}
#[tokio::test]
async fn invalid_mst_rejected() {
for bad in ["0312345", "12345678901", "031234567A", "0312345678-12", "0312345678 "] {
let ctx = TestContext::vn_residency_account().await;
let r = ctx.update_account_mst(ctx.account_id, bad).await;
assert!(r.is_err(), "bad mst accepted: {bad}");
}
}
// 5.6..5.9 — enum cardinality, audit emission
§7 — Dependencies
Upstream: TASK-CRM-001. Downstream: TASK-CRM-008 (validation skill), TASK-INV-007 (reads MST for hóa đơn). Cross-module: TASK-MEMORY-111 (PII scrub).
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| MST format invalid | CHECK constraint | 400 | fix input |
| Residency=vn-1 without MST | trigger/handler | 400 | provide MST |
| Residency change vn-1 → sg-1 | leave MST as-is | inherent | inherent |
| MST duplicate across tenants | per-tenant index OK (cross-tenant allowed) | inherent | inherent |
| TASK-CRM-008 skill validates external | future task | optional confirm via GDT | inherent |
| Account legacy missing fields | migration backfill | NULL preserved | manual fill |
| MST with whitespace | reject | 400 | trim client-side |
§11 — Implementation notes
- §11.1 MST regex:
^[0-9]{10}(-[0-9]{3})?$enforced in CHECK + Rust validator. - §11.2 PII: MST is government identifier per TASK-MEMORY-111; SHA256 in audit chain.
- §11.3 TASK-CRM-008 future skill will call GDT MST verification API for external confirm.
- §11.4 Account type picker shows VN names: "Doanh nghiệp tư nhân" / "TNHH 1 thành viên" / etc.
- §11.5 Migration backfill: NULL allowed for existing rows; CRO updates over time.
End of TASK-CRM-003 spec.