"INV revenue recognition — ASC 606 / IFRS 15 compliant deferred-revenue rollforward with monthly journal entries + per-engagement schedule"
§1 — Description (BCP-14 normative)
The INV service MUST ship revenue recognition at services/invoicing/src/recognition/ supporting 4 methods, monthly EOM rollforward, journal entry pairs, immutable snapshots, 4 memory audit kinds.
- MUST support 4 recognition methods per DEC-1560:
time_based: equal monthly amount over engagement.start_date → end_datemilestone_based: recognize when milestone achieved (TASK-PROJ-002 milestone marked complete)pct_completion: recognize by % of project hours billed (uses TASK-TIME-002 time entries)point_in_time: full recognition on invoice send (one-off services)
- MUST validate
recognition_methodagainst closed enum per DEC-1561.
- MUST build schedule at engagement creation via
scheduler.rs::build(engagement):
- time_based → equal split across N months
- milestone_based → per-milestone bucket
- pct_completion → recompute on hours change
- point_in_time → single bucket at send_date
- MUST run rollforward at EOM tenant_timezone per DEC-1562 via TASK-MCP-007 task:
- For each engagement, compute period revenue recognized
- Emit journal entry pair per DEC-1563
- Insert immutable snapshot row
- MUST generate journal entry pairs per DEC-1563:
- On invoice issuance:
DR AR / CR Deferred Revenue - On recognition:
DR Deferred Revenue / CR Revenue - Pairs balance (sum DR == sum CR per entry)
- MUST define tables at migration
0010: ```sql CREATE TABLE recognition_schedules ( schedule_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, engagement_id UUID NOT NULL, recognition_method TEXT NOT NULL CHECK (recognition_method IN ('time_based','milestone_based','pct_completion','point_in_time')), period_start DATE NOT NULL, period_end DATE NOT NULL, planned_amount NUMERIC(18,4) NOT NULL, recognized_amount NUMERIC(18,4) NOT NULL DEFAULT 0, currency TEXT NOT NULL, reconcile_status TEXT NOT NULL DEFAULT 'pending' CHECK (reconcile_status IN ('pending','partial','complete')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX recognition_schedule_period_idx ON recognition_schedules(tenant_id, engagement_id, period_start, period_end); ALTER TABLE recognition_schedules ENABLE ROW LEVEL SECURITY; CREATE POLICY rec_sched_rls ON recognition_schedules USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON recognition_schedules FROM cyberos_app; GRANT UPDATE (recognized_amount, reconcile_status, updated_at) ON recognition_schedules TO cyberos_app;
CREATE TABLE recognition_snapshots ( snapshot_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, period_end DATE NOT NULL, engagement_id UUID NOT NULL, period_revenue NUMERIC(18,4) NOT NULL, deferred_revenue_change NUMERIC(18,4) NOT NULL, ending_deferred_revenue NUMERIC(18,4) NOT NULL, currency TEXT NOT NULL, journal_entry_id UUID, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX rec_snap_period_idx ON recognition_snapshots(tenant_id, engagement_id, period_end); ALTER TABLE recognition_snapshots ENABLE ROW LEVEL SECURITY; CREATE POLICY rec_snap_rls ON recognition_snapshots USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON recognition_snapshots FROM cyberos_app; -- No GRANT UPDATE — snapshots immutable per DEC-1566
CREATE TABLE journal_entries ( journal_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, entry_date DATE NOT NULL, description TEXT NOT NULL, debit_account TEXT NOT NULL, credit_account TEXT NOT NULL, amount NUMERIC(18,4) NOT NULL CHECK (amount > 0), currency TEXT NOT NULL, engagement_id UUID, invoice_id UUID, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE journal_entries ENABLE ROW LEVEL SECURITY; CREATE POLICY journal_rls ON journal_entries USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON journal_entries FROM cyberos_app; ```
- MUST make
recognition_snapshotsimmutable per DEC-1566 — corrections via NEWprior_period_adjustmentsnapshot, never UPDATE.
- MUST emit 4 memory audit kinds per DEC-1565. PII scrub per TASK-MEMORY-111: amount/period_revenue SHA-256 hashed; engagement_id (uuid) ok.
- MUST thread trace_id from EOM cron → rollforward → schedule update → journal → snapshot.
- MUST use
rust_decimal::Decimalfor ALL amounts — never f64.
- MUST NOT mutate prior-period snapshot per DEC-1566.
- MUST NOT skip journal entry on any recognition event per DEC-1563.
§2 — Why this design
Why 4 methods (DEC-1560)? Covers consultancy spectrum — subscription, project (milestone), T&M consulting (pct-completion), one-off (point-in-time). ASC 606 enumerates these patterns.
Why immutable snapshots (DEC-1566)? Audit lineage requires period close to be unrevisable; restatements happen via prior-period adjustment journal entries.
Why monthly EOM (DEC-1562)? Aligns with month-end close cycle; weekly/quarterly are less common.
Why per-engagement schedule (DEC-1564)? Each engagement has independent contract terms; aggregation done at report time.
§3 — API contract
POST /v1/inv/recognition/schedules (CFO assigns method to engagement)
GET /v1/inv/recognition/schedules/{eng_id} (view schedule)
GET /v1/inv/recognition/snapshots/{period} (period rollforward)
POST /v1/inv/recognition/rollforward (manual trigger, CFO)
GET /v1/inv/recognition/journal-entries (audit query)
Sample schedule (time_based, 12-month $120k):
[
{"period_end": "2026-01-31", "planned_amount": 10000, "recognized_amount": 10000, "status": "complete"},
{"period_end": "2026-02-28", "planned_amount": 10000, "recognized_amount": 10000, "status": "complete"},
...
{"period_end": "2026-12-31", "planned_amount": 10000, "recognized_amount": 0, "status": "pending"}
]
Sample journal pair on rollforward:
[
{"date": "2026-05-31", "description": "May revenue recognition", "DR": "Deferred Revenue", "CR": "Revenue", "amount": 10000, "engagement_id": "..."},
{"date": "2026-05-31", "description": "May AR offset (already booked at issue)", "DR": "AR", "CR": "Deferred Revenue", "amount": 0} // no-op for pre-billed
]
§4 — Acceptance criteria
- 4 methods supported + cardinality test. 2. Schedule built at engagement creation. 3. EOM rollforward via TASK-MCP-007 cron. 4. Journal entries balance (DR sum = CR sum). 5. Snapshots immutable (no UPDATE/DELETE grant). 6. Prior-period adjustment via new snapshot. 7. rust_decimal for amounts (no f64). 8. 4 memory audit kinds. 9. PII scrubbed (amount SHA256). 10. RLS denies cross-tenant. 11. Trace_id preserved. 12. Multi-currency: schedule in engagement currency; report base via TASK-INV-002. 13. time_based: equal monthly split. 14. milestone_based: recognize on milestone complete event. 15. pct_completion: recompute on TASK-TIME-002 entry change. 16. point_in_time: full on invoice send. 17. Manual rollforward CFO-only. 18. Idempotent (UNIQUE on tenant+engagement+period). 19. Failure mid-rollforward → snapshot=failed + sev-1. 20. Reconcile_status auto-updates (pending→partial→complete).
§5 — Verification
#[tokio::test]
async fn time_based_equal_split() {
let ctx = TestContext::engagement_with_terms(120000, 12, "time_based").await;
let schedule = ctx.fetch_schedule(ctx.engagement_id).await;
assert_eq!(schedule.len(), 12);
for s in schedule { assert_eq!(s.planned_amount, dec!(10000)); }
}
#[tokio::test]
async fn snapshots_immutable() {
let ctx = TestContext::with_completed_period().await;
let snap = ctx.fetch_snapshot(ctx.period_end).await;
let result = ctx.try_mutate_snapshot(snap.snapshot_id).await;
assert!(result.is_err()); // RLS + REVOKE blocks
}
#[tokio::test]
async fn journal_entries_balance() {
let ctx = TestContext::with_rollforward_run().await;
let entries = ctx.fetch_journal_entries_for_period().await;
let total_dr: Decimal = entries.iter().map(|e| e.amount).sum();
let total_cr: Decimal = entries.iter().map(|e| e.amount).sum();
assert_eq!(total_dr, total_cr);
}
// 5.4..5.10
§6 — Skeleton
pub async fn rollforward(period_end: NaiveDate, tenant: &Tenant, db: &Db) -> Result<RollforwardResult> {
let engagements = db.fetch_active_engagements(tenant.id).await?;
let trace = current_span_trace_id();
for eng in engagements {
let period_revenue = match eng.recognition_method.as_str() {
"time_based" => time_based_amount(&eng, period_end),
"milestone_based" => milestone_revenue(&eng, period_end, db).await?,
"pct_completion" => pct_completion_revenue(&eng, period_end, db).await?,
"point_in_time" => point_in_time_revenue(&eng, period_end, db).await?,
_ => unreachable!()
};
let journal_id = journal_entry::create_recognition_pair(&eng, period_revenue, period_end, db).await?;
db.insert_snapshot(eng.id, period_end, period_revenue, journal_id, trace).await?;
db.update_schedule_recognized(&eng, period_end, period_revenue).await?;
audit::emit("inv.revenue_recognized", json!({...}), trace).await?;
}
audit::emit("inv.revenue_rollforward_completed", json!({"period_end": period_end}), trace).await?;
Ok(RollforwardResult{snapshots_created: engagements.len()})
}
§7 — Dependencies
Upstream: TASK-INV-001. Cross-module: TASK-MCP-007 (cron), TASK-TIME-002 (hours for pct_completion), TASK-PROJ-002 (milestones), TASK-MEMORY-111 (PII), TASK-AUTH-101 (CFO role).
§8 — Sample payloads (see §3)
§9 — Open questions
None blocking — ASC 606 + IFRS 15 patterns well-established.
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Engagement no method set | scheduler check | sev-2 + skip rollforward | CFO assigns method |
| Journal entries don't balance | post-condition check | rollback + sev-1 | data investigation |
| Snapshot mutation attempt | RLS + REVOKE | DB error | inherent |
| pct_completion negative hours | scheduler validate | sev-2 + skip period | TASK-TIME-002 fix |
| Milestone never completes | indefinite deferral | annual sweep alerts | CFO review |
| Mid-month engagement add | next EOM rollforward picks up | inherent | inherent |
| Mid-rollforward crash | partial snapshots | resume from last successful eng | re-run |
| Multi-currency engagement w/o FX | rollforward fail | sev-1 | provide FX |
| Engagement modified mid-rollforward | snapshot SQL | uses snapshot row state | inherent |
| Decimal precision drift | rust_decimal | exact | inherent |
| Period not yet ended | guard | reject | wait |
| Duplicate rollforward run | UNIQUE constraint | ON CONFLICT DO NOTHING | inherent |
§11 — Implementation notes
- §11.1 EOM tenant_timezone: midnight of first of next month; jitter ±30min to avoid thundering herd.
- §11.2 pct_completion formula:
recognized = (hours_completed / hours_estimated) * total_contract; cap at 100%. - §11.3 memory audit body: engagement_id (uuid), period_end (date), period_revenue SHA256 hashed.
- §11.4 Snapshots are reportable via TASK-INV-009 + future financial-statements task.
- §11.5 Prior-period adjustment pattern: new snapshot with
description: 'PPA: <reason>'; original snapshot unchanged.
End of TASK-INV-011 spec.