Task — engineering-spec@1

"CRM vietnam-bank-transfer skill — VietQR payment image generation for deal collection with embedded amount + memo + bank routing"

draftTASK-CRM-009
module crm · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-CRM-001 · blocks none

§1 — Description (BCP-14 normative)

The CRM service MUST ship vietnam-bank-transfer@1 skill at services/crm/src/vn/vietqr_skill.rs generating VietQR PNG with embedded payment metadata, per-tenant bank config, 3 memory audit kinds.

  1. MUST register skill vietnam-bank-transfer@1 per DEC-1690.
  1. MUST validate qr_purpose against closed enum per DEC-1691.
  1. MUST require tenant bank config at table tenant_bank_config: ``sql CREATE TABLE tenant_bank_config ( tenant_id UUID PRIMARY KEY, bank_bin TEXT NOT NULL, -- 6-digit bank identifier per Napas account_number TEXT NOT NULL, account_holder_name TEXT NOT NULL, set_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE tenant_bank_config ENABLE ROW LEVEL SECURITY; CREATE POLICY bank_config_rls ON tenant_bank_config USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); GRANT UPDATE (bank_bin, account_number, account_holder_name, set_by, updated_at) ON tenant_bank_config TO cyberos_app; ``
  1. MUST gate bank config writes to CFO role per DEC-1693 via TASK-AUTH-101.
  1. MUST generate at qr_generator.rs::generate(tenant_config, amount, memo) per VietQR spec DEC-1692 — output PNG bytes; embedded payload BCD\n01\n{bank_bin}\n{account_number}\n{amount}\n{memo}.
  1. MUST generate memo per DEC-1694 — template {tenant_short}-{deal_id_first_8}. Must match TASK-INV-005 reconciliation pattern.
  1. MUST expose skill endpoint: ``text POST /v1/crm/skill/vietnam-bank-transfer body: {qr_purpose, amount_vnd, deal_id?, memo_override?} ``
  1. MUST validate amount_vnd > 0 and ≤ 1B VND (Napas max single transfer).
  1. MUST emit 3 memory audit kinds per DEC-1695. PII per TASK-MEMORY-111: account_number SHA-256 hashed; amount SHA256 in chain.
  1. MUST thread trace_id from skill call → generator → audit.
  1. MUST NOT generate without bank config per DEC-1693 — return 412 (Precondition Failed) with link to config.
  1. MUST NOT allow non-CFO write per DEC-1693 — 403.

§2 — Why this design

Why VietQR (DEC-1690)? Napas-standard QR for VN domestic transfers; scannable by every VN banking app.

Why CFO-only bank config (DEC-1693)? Misconfigured bank routes payments to wrong account = direct money loss; CFO has authority.

Why memo template (DEC-1694)? TASK-INV-005 reconciles inbound VietQR payments by memo string; mismatch = orphan payment.

Why amount cap (1B VND, DEC implicit)? Napas spec; transfers >1B require separate higher-ceiling rail (TASK-TEN-102 covers).


§3 — API contract

POST   /v1/crm/skill/vietnam-bank-transfer
PUT    /v1/crm/bank-config                (CFO-only)
GET    /v1/crm/bank-config                (read by skill caller)

Sample skill call:

{
  "qr_purpose": "deal_collection",
  "amount_vnd": 50000000,
  "deal_id": "uuid",
  "memo_override": null
}

Response:

{
  "qr_png_base64": "iVBORw0KGgo...",
  "memo": "CSV-a1b2c3d4",
  "amount_vnd": 50000000,
  "expires_at": null
}

§4 — Acceptance criteria

  1. Skill registered as vietnam-bank-transfer@1. 2. Bank config required (412 if not set). 3. CFO-only bank config (403 for others). 4. PNG generated with VietQR payload. 5. Memo template applied (tenant_short + deal_id_8). 6. Memo override accepted (optional). 7. qr_purpose enum 3 + cardinality test. 8. Amount > 0 enforced. 9. Amount ≤ 1B VND enforced. 10. 3 memory audit kinds emitted. 11. PII scrubbed (account_number+amount SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Memo matches TASK-INV-005 regex. 15. PNG render deterministic for same input. 16. Bank config update increments updated_at. 17. bank_bin format 6-digit numeric. 18. account_number max 20 chars. 19. account_holder_name required. 20. No QR for non-VN tenant amounts (currency=VND only).

§5 — Verification

#[tokio::test]
async fn generates_png_with_correct_memo() {
    let ctx = TestContext::with_bank_config().await;
    let r = ctx.gen_qr("deal_collection", 50_000_000, Some(ctx.deal_id), None).await;
    assert!(r.qr_png_base64.starts_with("iVBOR"));  // PNG magic
    assert!(r.memo.starts_with("CSV-"));
    assert_eq!(r.memo.len(), 4 + 8);  // prefix + 8-char deal
}

#[tokio::test]
async fn rejects_without_bank_config() {
    let ctx = TestContext::new_tenant().await;
    let r = ctx.try_gen_qr("manual_request", 1000, None, None).await;
    assert_eq!(r.status_code, 412);
}

#[tokio::test]
async fn non_cfo_rejected_on_config_write() {
    let ctx = TestContext::with_non_cfo_user().await;
    let r = ctx.update_bank_config_as(ctx.am_user, "970422", "123456", "Cyberskill JSC").await;
    assert_eq!(r.status_code, 403);
}

// 5.4..5.8 — amount limits, memo override, enum cardinality, audit

§7 — Dependencies

Upstream: TASK-CRM-001. Cross-module: TASK-SKILL-108 (skill registry), TASK-AUTH-101 (CFO role), TASK-INV-005 (reconciliation memo regex), TASK-TEN-102 (>1B rail), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Bank config missingprecondition412 + config linkCFO configures
Amount > 1B VNDvalidate400 with rail-switch suggestionuse TASK-TEN-102
Amount ≤ 0validate400provide valid
PNG render fail (qrcode crate err)catch500 + sev-2 auditretry
Deal_id doesn't exist (purpose=deal_collection)validate404provide valid
Memo length >100 charsvalidate400 (Napas limit)shorten
Bank_bin invalid (not 6-digit)validatereject config writeprovide valid
Non-CFO write attemptRLS + role403request CFO
Cross-tenant config viewRLS0 rowsinherent
Currency != VNDfuture check400use other rail

§11 — Implementation notes


End of TASK-CRM-009 spec.