Task — engineering-spec@1

"INV AR aging report — current/30/60/90/120+ bucket rollup per customer + per engagement with as-of date determinism"

draftTASK-INV-009
module inv · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-INV-001 · blocks TASK-INV-010

§1 — Description (BCP-14 normative)

The INV service MUST ship AR aging at services/invoicing/src/reports/aging.rs returning 6-bucket overdue rollup per as_of_date with per-customer / per-engagement / tenant-wide variants, multi-currency conversion via TASK-INV-002, 1 memory audit kind.

  1. MUST expose POST /v1/inv/reports/aging body { as_of_date, group_by?: 'customer'|'engagement'|'tenant', base_currency? }. Auth via TASK-AUTH-101 (CFO + accountant roles).
  1. MUST bucket via aging_bucketer.rs::bucket(invoice, as_of_date):
  1. MUST use as_of_date for ALL calculations per DEC-1541 — never now(). Same params → same SQL → same result.
  1. MUST support multi-currency per DEC-1544: when base_currency differs from invoice currency, convert via TASK-INV-002 fx_snapshot(currency_pair, as_of_date). Missing FX → fall back to nearest-prior with sev-2 audit.
  1. MUST return rollup per group_by:
  1. MUST exclude cancelled and paid invoices from buckets; include sent, partial_paid, overdue statuses.
  1. MUST emit inv.aging_report_generated per DEC-1546 with {as_of_date, group_by, customer_count, invoice_count, total_outstanding_hash} — total amount SHA-256 hashed per TASK-MEMORY-111 (treat AR totals as confidential).
  1. MUST thread trace_id from CFO action through bucketer + FX lookup + audit emission.
  1. MUST NOT use now() per DEC-1541; reject if as_of_date missing.
  1. MUST NOT bucket on full invoice when partial-paid (use outstanding_balance per DEC-1543).

§2 — Why this design

Why 6 buckets (DEC-1540)? Industry standard (current/30/60/90/120+); CFO conditioning + tax/audit templates.

Why as-of determinism (DEC-1541)? Monthly close reports must be re-runnable years later with same input → same output. Without this, audits fail.

Why outstanding_balance bucketing (DEC-1543)? A 90-day-old invoice partially paid to $0 should be current, not overdue_90. Bucketing on full amount mis-prioritizes collection.

Why FX at as-of (DEC-1544)? Aging report = financial snapshot at date X; FX must match. Otherwise dashboard shows wrong USD totals.


§3 — API contract

POST   /v1/inv/reports/aging              (CFO/accountant)
GET    /v1/inv/reports/aging?as_of=...    (cached if recent)

Sample request:

{
  "as_of_date": "2026-05-31",
  "group_by": "customer",
  "base_currency": "USD"
}

Sample response:

{
  "as_of_date": "2026-05-31",
  "base_currency": "USD",
  "buckets": [
    {
      "customer_id": "uuid",
      "customer_name": "Acme Corp",
      "current": 5000.00,
      "overdue_30": 2500.00,
      "overdue_60": 1200.00,
      "overdue_90": 0,
      "overdue_120": 0,
      "overdue_120plus": 800.00,
      "total_outstanding": 9500.00,
      "invoice_count": 7,
      "currency": "USD",
      "fx_converted": false
    }
  ],
  "summary": {
    "total_outstanding": 9500.00,
    "customer_count": 1,
    "invoice_count": 7
  }
}

§4 — Acceptance criteria

  1. 6 bucket categories. 2. Closed enum cardinality 6. 3. As_of_date required (400 if missing). 4. Determinism: same params = same result. 5. Outstanding_balance used (not total). 6. Multi-currency conversion via TASK-INV-002. 7. FX at as_of_date (not now). 8. Cancelled + paid excluded. 9. Sent/partial_paid/overdue included. 10. Group_by customer/engagement/tenant. 11. 1 memory audit kind emitted. 12. PII scrubbed (total hashed). 13. RLS denies cross-tenant. 14. CFO + accountant roles only. 15. Trace_id preserved. 16. FX missing → nearest-prior + sev-2 audit. 17. Empty result returns empty array (not 404). 18. Days_overdue boundary edges (0,1,30,31,etc) correct per DEC-1540. 19. Pagination supported for >1000 customers. 20. JSON output deterministic ordering by customer_id.

§5 — Verification

#[tokio::test]
async fn six_buckets_correct() {
    let ctx = TestContext::vn_tenant_with_invoices_at_various_ages().await;
    let report = ctx.aging_report("2026-05-31", "customer", "VND").await;
    assert_eq!(report.buckets[0].current, dec!(5000));
    assert_eq!(report.buckets[0].overdue_30, dec!(2500));
    // ... etc
}

#[tokio::test]
async fn as_of_determinism() {
    let ctx = TestContext::vn_tenant_with_invoices().await;
    let r1 = ctx.aging_report("2026-05-31", "tenant", "VND").await;
    let r2 = ctx.aging_report("2026-05-31", "tenant", "VND").await;
    assert_eq!(r1, r2);
}

#[tokio::test]
async fn partial_paid_uses_outstanding() {
    let ctx = TestContext::invoice_paid_50pct(1000, 90).await;  // 90d old, 50% paid
    let report = ctx.aging_report("2026-05-31", "tenant", "VND").await;
    assert_eq!(report.buckets[0].overdue_90, dec!(500));  // not 1000
}

// 5.4..5.10 — FX, audit, enum cardinality, cancelled excluded

§6 — Skeleton

pub async fn generate(req: AgingRequest, db: &Db) -> Result<AgingReport> {
    if req.as_of_date.is_none() { return Err(400.into()); }
    let invoices = db.fetch_invoices_for_aging(req.as_of_date, req.group_by).await?;
    let buckets = invoices.into_iter()
        .filter(|i| !matches!(i.status, "cancelled" | "paid"))
        .map(|i| {
            let days_overdue = (req.as_of_date - i.due_date).num_days();
            let bucket = aging_bucketer::bucket(days_overdue);
            let amount = if let Some(base) = &req.base_currency {
                fx::convert(i.outstanding_balance, i.currency, base, req.as_of_date).await?
            } else { i.outstanding_balance };
            (i.customer_id, bucket, amount)
        })
        .collect();
    audit::emit("inv.aging_report_generated", json!({
        "as_of_date": req.as_of_date, "customer_count": ..., "total_outstanding_hash": sha256(total)
    }), trace).await?;
    Ok(roll_up(buckets, req.group_by))
}

§7 — Dependencies

Upstream: TASK-INV-001, TASK-INV-002. Downstream: TASK-INV-010 (dunning uses aging output). Cross-module: TASK-AUTH-101 (role check), TASK-MEMORY-111 (PII).

§8 — Sample payloads (see §3)

§9 — Open questions

None blocking.

§10 — Failure modes

FailureDetectionOutcomeRecovery
as_of_date missingvalidate400provide date
FX rate missing for datesnapshot lookupnearest-prior + sev-2 auditper TASK-INV-002
Customer has no invoicesempty groupomit from responseinherent
Invoice with NULL due_datefilterexcluded + warning auditdata fix
Currency conversion fail (no FX)hard failsev-1 + report failsmanual FX entry
Massive tenant (>10k invoices)paginationcursor-basedinherent
Concurrent aging while invoice updatedsnapshot SQLuses snapshot row stateinherent
Decimal precision driftuse rust_decimal4 decimal places preservedinherent
Bucket edge (1d vs 0d)strict <= per specper DEC-1540tests verify
Cross-tenant queryRLS0 rowsinherent

§11 — Implementation notes


End of TASK-INV-009 spec.