"INV CUO dunning draft — auto-generate polite/firm/legal-warning email drafts per aging bucket + CFO review queue + send-via-TASK-EMAIL-009"
§1 — Description (BCP-14 normative)
The INV service MUST ship dunning draft generation at services/invoicing/src/dunning/ triggered daily, tone-scaled to aging bucket, drafts queued for CFO approval (never auto-send), send via TASK-EMAIL-009, 4 memory audit kinds.
- MUST schedule daily scanner at 09:00 tenant_timezone per DEC-1554 via TASK-MCP-007 task or cron.
scanner.rs::scan(tenant)calls TASK-INV-009aging.generate({as_of_date: today, group_by: 'engagement'}).
- MUST map bucket → tone per DEC-1550:
- overdue_30 → polite
- overdue_60 → firm
- overdue_90 → urgent
- overdue_120 + overdue_120plus → legal_warning
- MUST validate
dunning_toneagainst closed enum per DEC-1551; reject invalid values.
- MUST generate draft via
draft_generator.rs::generate(invoice, tone, template)— TASK-AI-003 call with template + invoice context. Templates loaded fromtemplate_loader.rs::load(tenant, tone).
- MUST be idempotent per DEC-1554: skip if
dunning_draftsrow exists for(invoice_id, tone). UseUNIQUE(tenant_id, invoice_id, tone).
- MUST queue draft for CFO review — NEVER auto-send per DEC-1553. CFO sees in dunning queue UI (TASK-CUO-101); approves or dismisses.
- MUST on CFO approval: call TASK-EMAIL-009 send_message with draft body, log
dunning_email_sentaudit.
- MUST define
dunning_draftstable at migration0009: ``sql CREATE TABLE dunning_drafts ( draft_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, invoice_id UUID NOT NULL, engagement_id UUID NOT NULL, customer_id UUID NOT NULL, aging_bucket TEXT NOT NULL, tone TEXT NOT NULL CHECK (tone IN ('polite','firm','urgent','legal_warning')), draft_subject TEXT NOT NULL, draft_body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','approved','dismissed','sent','failed_send')), reviewed_by UUID, reviewed_at TIMESTAMPTZ, email_message_id UUID, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX dunning_dedup_idx ON dunning_drafts(tenant_id, invoice_id, tone); ALTER TABLE dunning_drafts ENABLE ROW LEVEL SECURITY; CREATE POLICY dunning_drafts_rls ON dunning_drafts USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON dunning_drafts FROM cyberos_app; GRANT UPDATE (status, reviewed_by, reviewed_at, email_message_id) ON dunning_drafts TO cyberos_app;``
- MUST emit 4 memory audit kinds per DEC-1555. PII: draft_body customer-name/amounts scrubbed via TASK-MEMORY-111 SHA256 hash.
- MUST thread trace_id from cron/scanner → generator → CFO review → email send.
- MUST NOT auto-send any draft per DEC-1553 — UI shows approve/dismiss only.
- MUST NOT generate legal_warning draft without escalation flag — surfaces in CFO inbox with red banner.
§2 — Why this design
Why tone scales by bucket (DEC-1550)? Industry-standard collection escalation; reduces churn vs all-firm approach.
Why manual approval always (DEC-1553)? Legal-warning emails carry liability + customer relationship risk; auto-send is unacceptable error mode.
Why idempotent scan (DEC-1554)? Daily scan must not duplicate drafts; CFO sees clean queue.
Why TASK-AI-003 generation (DEC-1552)? Personalization (customer name, invoice ref, outstanding balance) requires templated AI inference; static templates feel robotic.
§3 — API contract
GET /v1/inv/dunning/drafts (list pending review, CFO)
POST /v1/inv/dunning/drafts/{id}/approve (CFO sends)
POST /v1/inv/dunning/drafts/{id}/dismiss (CFO rejects)
POST /v1/inv/dunning/scan (CFO manual trigger)
Sample draft response:
{
"draft_id": "uuid",
"invoice_id": "uuid",
"customer_id": "uuid",
"aging_bucket": "overdue_60",
"tone": "firm",
"draft_subject": "Reminder: Invoice INV-2026-042 — 45 days past due",
"draft_body": "Dear {customer_name},\n\nThis is a follow-up regarding...",
"status": "pending_review",
"created_at": "2026-05-17T09:00:00Z"
}
§4 — Acceptance criteria
- Daily scan at 09:00 tenant_timezone. 2. Bucket→tone mapping correct. 3. Closed enum 4 values + cardinality test. 4. Idempotent (UNIQUE on tenant+invoice+tone). 5. TASK-AI-003 generates draft. 6. Drafts queued for CFO review (never auto-send). 7. Approve → TASK-EMAIL-009 send. 8. Dismiss → status=dismissed (not deleted). 9. 4 memory audit kinds emitted. 10. PII scrubbed (customer/amount → SHA256). 11. RLS denies cross-tenant. 12. Legal_warning has red-banner UX flag. 13. Trace_id preserved. 14. Template customization per tenant. 15. Manual scan trigger CFO-only. 16. Sent draft status=sent + email_message_id linked. 17. Failed send → status=failed_send + retry. 18. Append-only (REVOKE UPDATE except status/review). 19. Aging bucket re-classified on re-scan (e.g. 60→90). 20. Multiple invoices same customer → one draft each (per invoice, not aggregate).
§5 — Verification
#[tokio::test]
async fn bucket_to_tone_mapping() {
assert_eq!(map_tone("overdue_30"), Tone::Polite);
assert_eq!(map_tone("overdue_60"), Tone::Firm);
assert_eq!(map_tone("overdue_90"), Tone::Urgent);
assert_eq!(map_tone("overdue_120"), Tone::LegalWarning);
assert_eq!(map_tone("overdue_120plus"), Tone::LegalWarning);
}
#[tokio::test]
async fn never_auto_sends() {
let ctx = TestContext::with_overdue_invoices().await;
ctx.run_daily_scan().await;
let sent_emails = ctx.email_send_count().await;
assert_eq!(sent_emails, 0);
let drafts = ctx.fetch_drafts().await;
assert!(drafts.iter().all(|d| d.status == "pending_review"));
}
#[tokio::test]
async fn idempotent_scan_no_duplicates() {
let ctx = TestContext::with_overdue_invoices().await;
ctx.run_daily_scan().await;
let count1 = ctx.draft_count().await;
ctx.run_daily_scan().await;
let count2 = ctx.draft_count().await;
assert_eq!(count1, count2);
}
// 5.4..5.10 — template render, audit, RLS, AI integration, send flow
§6 — Skeleton
pub async fn scan(tenant: &Tenant, db: &Db) -> Result<ScanResult> {
let report = aging::generate(AgingRequest{
as_of_date: today_in(tenant.timezone),
group_by: Group::Engagement, base_currency: None
}, db).await?;
let mut created = 0;
for bucket_row in report.buckets {
for invoice in bucket_row.invoices {
let tone = map_tone(&invoice.bucket);
if db.draft_exists(tenant.id, invoice.id, tone).await? { continue; }
let template = template_loader::load(tenant, tone).await?;
let body = draft_generator::generate(&invoice, tone, &template).await?;
db.insert_draft(invoice, tone, body).await?;
audit::emit("inv.dunning_draft_generated", json!({...}), trace).await?;
created += 1;
}
}
Ok(ScanResult{drafts_created: created})
}
§7 — Dependencies
Upstream: TASK-INV-009 (aging), TASK-EMAIL-009 (send). Cross-module: TASK-AI-003 (template generation), TASK-CUO-101 (review UI), TASK-MCP-007 (cron).
§8 — Sample payloads (see §3)
§9 — Open questions
None blocking — CFO can iterate templates after launch.
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| TASK-AI-003 timeout | generation fail | retry 3x then sev-2 | manual retry |
| Template missing | load fail | use default + sev-2 audit | CFO uploads |
| Customer has no email | scanner check | skip + audit warning | data fix |
| Duplicate scan run | UNIQUE constraint | skip | inherent |
| CFO double-approves | UPDATE WHERE pending | only first wins | inherent |
| Send fail (bounce) | TASK-EMAIL-009 status | status=failed_send + retry | CFO investigates |
| Aging changes mid-scan | snapshot semantics | uses scanner-time snapshot | next scan picks up |
| Customer paid mid-scan | aging excludes paid | invoice removed | inherent |
| Cron skipped (system down) | last_run check | catch-up on next boot | inherent |
| Legal-warning escalation missed | red-banner UX | CFO see immediately | UX gate |
§11 — Implementation notes
- §11.1 Templates use
{customer_name} {invoice_ref} {outstanding_balance} {days_overdue}placeholders. - §11.2 TASK-AI-003 prompt: "Write a {tone} payment reminder using this template..." — model fills placeholders + softens/firms tone.
- §11.3 memory audit body: customer_id (uuid OK), tone, bucket; draft_body SHA256 hashed.
- §11.4 Cron via TASK-MCP-007 with
kind: 'inv.dunning_daily_scan', tenant_id arg. - §11.5 Legal warning template includes "this is not legal advice" disclaimer + link to legal team contact.
End of TASK-INV-010 spec.