Task — engineering-spec@1

"INV CUO dunning draft — auto-generate polite/firm/legal-warning email drafts per aging bucket + CFO review queue + send-via-TASK-EMAIL-009"

draftTASK-INV-010
module inv · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-INV-009, TASK-EMAIL-009, TASK-CUO-101 · blocks none

§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.

  1. 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-009 aging.generate({as_of_date: today, group_by: 'engagement'}).
  1. MUST map bucket → tone per DEC-1550:
  1. MUST validate dunning_tone against closed enum per DEC-1551; reject invalid values.
  1. MUST generate draft via draft_generator.rs::generate(invoice, tone, template) — TASK-AI-003 call with template + invoice context. Templates loaded from template_loader.rs::load(tenant, tone).
  1. MUST be idempotent per DEC-1554: skip if dunning_drafts row exists for (invoice_id, tone). Use UNIQUE(tenant_id, invoice_id, tone).
  1. MUST queue draft for CFO review — NEVER auto-send per DEC-1553. CFO sees in dunning queue UI (TASK-CUO-101); approves or dismisses.
  1. MUST on CFO approval: call TASK-EMAIL-009 send_message with draft body, log dunning_email_sent audit.
  1. MUST define dunning_drafts table at migration 0009: ``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; ``
  1. MUST emit 4 memory audit kinds per DEC-1555. PII: draft_body customer-name/amounts scrubbed via TASK-MEMORY-111 SHA256 hash.
  1. MUST thread trace_id from cron/scanner → generator → CFO review → email send.
  1. MUST NOT auto-send any draft per DEC-1553 — UI shows approve/dismiss only.
  1. 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

  1. 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

FailureDetectionOutcomeRecovery
TASK-AI-003 timeoutgeneration failretry 3x then sev-2manual retry
Template missingload failuse default + sev-2 auditCFO uploads
Customer has no emailscanner checkskip + audit warningdata fix
Duplicate scan runUNIQUE constraintskipinherent
CFO double-approvesUPDATE WHERE pendingonly first winsinherent
Send fail (bounce)TASK-EMAIL-009 statusstatus=failed_send + retryCFO investigates
Aging changes mid-scansnapshot semanticsuses scanner-time snapshotnext scan picks up
Customer paid mid-scanaging excludes paidinvoice removedinherent
Cron skipped (system down)last_run checkcatch-up on next bootinherent
Legal-warning escalation missedred-banner UXCFO see immediatelyUX gate

§11 — Implementation notes


End of TASK-INV-010 spec.