"INV AR aging report — current/30/60/90/120+ bucket rollup per customer + per engagement with as-of date determinism"
§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.
- MUST expose
POST /v1/inv/reports/agingbody{ as_of_date, group_by?: 'customer'|'engagement'|'tenant', base_currency? }. Auth via TASK-AUTH-101 (CFO + accountant roles).
- MUST bucket via
aging_bucketer.rs::bucket(invoice, as_of_date):
- Compute
days_overdue = as_of_date - invoice.due_date. - Map to enum per DEC-1542:
current(≤0),overdue_30(1-30),overdue_60(31-60),overdue_90(61-90),overdue_120(91-120),overdue_120plus(>120). - Use
invoice.outstanding_balance(not total) per DEC-1543.
- MUST use as_of_date for ALL calculations per DEC-1541 — never
now(). Same params → same SQL → same result.
- MUST support multi-currency per DEC-1544: when
base_currencydiffers from invoice currency, convert via TASK-INV-002fx_snapshot(currency_pair, as_of_date). Missing FX → fall back to nearest-prior with sev-2 audit.
- MUST return rollup per group_by:
customer:[{customer_id, current, overdue_30, ..., total_outstanding, currency}]engagement:[{engagement_id, ..., total_outstanding}]tenant: single row with bucket sums
- MUST exclude
cancelledandpaidinvoices from buckets; includesent,partial_paid,overduestatuses.
- MUST emit
inv.aging_report_generatedper 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).
- MUST thread trace_id from CFO action through bucketer + FX lookup + audit emission.
- MUST NOT use
now()per DEC-1541; reject ifas_of_datemissing.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| as_of_date missing | validate | 400 | provide date |
| FX rate missing for date | snapshot lookup | nearest-prior + sev-2 audit | per TASK-INV-002 |
| Customer has no invoices | empty group | omit from response | inherent |
| Invoice with NULL due_date | filter | excluded + warning audit | data fix |
| Currency conversion fail (no FX) | hard fail | sev-1 + report fails | manual FX entry |
| Massive tenant (>10k invoices) | pagination | cursor-based | inherent |
| Concurrent aging while invoice updated | snapshot SQL | uses snapshot row state | inherent |
| Decimal precision drift | use rust_decimal | 4 decimal places preserved | inherent |
| Bucket edge (1d vs 0d) | strict <= per spec | per DEC-1540 | tests verify |
| Cross-tenant query | RLS | 0 rows | inherent |
§11 — Implementation notes
- §11.1 Bucketer is pure function; deterministic input → output.
- §11.2 SQL uses
WHERE due_date < as_of_date AND status NOT IN ('cancelled','paid'). - §11.3 FX conversion at row level (not aggregate) — preserves per-invoice currency context.
- §11.4 memory audit total_outstanding hashed (SHA256(amount.to_string()) per TASK-MEMORY-111).
- §11.5 Aging report is read-only; no row mutations; no .lock needed.
End of TASK-INV-009 spec.