From 3247bc9f68c134bb62078e3937f40d93e27729a1 Mon Sep 17 00:00:00 2001 From: shaiananvari8 <228813044+shaiananvari8@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:43:43 -0500 Subject: [PATCH] Add revenue recognition deferral guard --- .../package.json | 20 + revenue-recognition-deferral-guard/readme.md | 38 ++ .../reports/revenue-recognition-packet.json | 141 ++++++ .../reports/revenue-recognition-report.md | 38 ++ .../reports/summary.svg | 18 + .../scripts/demo.js | 29 ++ .../src/index.js | 457 ++++++++++++++++++ .../test/index.test.js | 98 ++++ 8 files changed, 839 insertions(+) create mode 100644 revenue-recognition-deferral-guard/package.json create mode 100644 revenue-recognition-deferral-guard/readme.md create mode 100644 revenue-recognition-deferral-guard/reports/revenue-recognition-packet.json create mode 100644 revenue-recognition-deferral-guard/reports/revenue-recognition-report.md create mode 100644 revenue-recognition-deferral-guard/reports/summary.svg create mode 100644 revenue-recognition-deferral-guard/scripts/demo.js create mode 100644 revenue-recognition-deferral-guard/src/index.js create mode 100644 revenue-recognition-deferral-guard/test/index.test.js diff --git a/revenue-recognition-deferral-guard/package.json b/revenue-recognition-deferral-guard/package.json new file mode 100644 index 00000000..033713f4 --- /dev/null +++ b/revenue-recognition-deferral-guard/package.json @@ -0,0 +1,20 @@ +{ + "name": "revenue-recognition-deferral-guard", + "version": "1.0.0", + "description": "Synthetic deferred revenue and recognition guard for SCIBASE revenue infrastructure.", + "type": "module", + "private": true, + "scripts": { + "check": "node --check src/index.js && node --check test/index.test.js && node --check scripts/demo.js", + "test": "node --test test/index.test.js", + "demo": "node scripts/demo.js" + }, + "keywords": [ + "scibase", + "revenue", + "deferred-revenue", + "recognition", + "finance-controls" + ], + "license": "MIT" +} diff --git a/revenue-recognition-deferral-guard/readme.md b/revenue-recognition-deferral-guard/readme.md new file mode 100644 index 00000000..6003d923 --- /dev/null +++ b/revenue-recognition-deferral-guard/readme.md @@ -0,0 +1,38 @@ +# Revenue Recognition Deferral Guard + +This module is a focused SCIBASE.AI issue #20 submission for Revenue Infrastructure. + +It validates synthetic finance-close packets before revenue is accepted as recognized. The guard checks annual subscription service periods, AI compute credit consumption, licensing/export delivery evidence, and allocation reconciliation so unearned amounts stay in deferred revenue until delivery is supported. + +## What It Covers + +- Performance obligation allocation reconciliation against invoice totals. +- Subscription revenue recognition based on elapsed service period. +- AI compute-credit revenue recognition only after consumption or expiry. +- Licensing/export revenue requiring delivery evidence before close. +- Deterministic finance remediation actions for held obligations. + +## What It Does Not Do + +- No processor, financial-account, tax-filing, invoice-send, refund-send, or external service calls. +- No personal billing data, cards, account numbers, payout details, or real customer records. +- No live SCIBASE revenue is read or mutated. +- No refund-liability, cancellation, priority-support, quote approval, or named-seat roster module is reimplemented. + +## Reviewer Path + +```bash +npm run check +npm test +npm run demo +``` + +Generated reviewer artifacts: + +- `reports/revenue-recognition-packet.json` +- `reports/revenue-recognition-report.md` +- `reports/summary.svg` + +## Claim + +Use `/claim #20` in the pull request body. diff --git a/revenue-recognition-deferral-guard/reports/revenue-recognition-packet.json b/revenue-recognition-deferral-guard/reports/revenue-recognition-packet.json new file mode 100644 index 00000000..c7420c7e --- /dev/null +++ b/revenue-recognition-deferral-guard/reports/revenue-recognition-packet.json @@ -0,0 +1,141 @@ +{ + "title": "SCIBASE Revenue Recognition Deferral Guard", + "issue": "SCIBASE.AI#20", + "claim": "/claim #20", + "purpose": "Prevent revenue close packets from recognizing subscription, compute-credit, or licensing revenue before delivery evidence exists.", + "evaluation": { + "status": "block_close", + "asOf": "2026-06-04T00:00:00.000Z", + "packetId": "scibase-revenue-recognition-close-2026-06", + "digest": "8b6865927e5e430b33924f2853f0d9c0e7c005bb6002eabd6250d82d57c0ebe2", + "totals": { + "contracts": 2, + "obligations": 3, + "acceptedRecognizedCents": 0, + "allowedRecognizedCents": 908800, + "deferredCents": 2391200, + "blockers": 6, + "warnings": 0, + "heldObligations": 3 + }, + "blockers": [ + { + "code": "premature_revenue_recognition", + "contractId": "contract:lab-annual-pro", + "obligationId": "obl:annual-subscription", + "recognizedCents": 1800000, + "allowedRecognizedCents": 759452, + "message": "Revenue is recognized before the obligation is sufficiently delivered." + }, + { + "code": "premature_revenue_recognition", + "contractId": "contract:lab-annual-pro", + "obligationId": "obl:compute-credits", + "recognizedCents": 450000, + "allowedRecognizedCents": 120000, + "message": "Revenue is recognized before the obligation is sufficiently delivered." + }, + { + "code": "usage_credits_recognized_before_consumption", + "contractId": "contract:lab-annual-pro", + "obligationId": "obl:compute-credits", + "consumedCents": 120000, + "recognizedCents": 450000, + "message": "Usage credit revenue exceeds consumed or expired value." + }, + { + "code": "missing_recognition_evidence", + "contractId": "contract:lab-annual-pro", + "obligationId": "obl:compute-credits", + "message": "Recognized revenue lacks required delivery evidence." + }, + { + "code": "premature_revenue_recognition", + "contractId": "contract:consortium-license", + "obligationId": "obl:analytics-license-export", + "recognizedCents": 900000, + "allowedRecognizedCents": 29348, + "message": "Revenue is recognized before the obligation is sufficiently delivered." + }, + { + "code": "missing_recognition_evidence", + "contractId": "contract:consortium-license", + "obligationId": "obl:analytics-license-export", + "message": "Recognized revenue lacks required delivery evidence." + } + ], + "warnings": [], + "actions": [], + "obligationActions": [ + { + "contractId": "contract:lab-annual-pro", + "obligationId": "obl:annual-subscription", + "kind": "subscription", + "decision": "hold", + "allocatedCents": 1800000, + "recognizedCents": 1800000, + "allowedRecognizedCents": 759452, + "deferredCents": 1040548, + "reasons": [ + "premature_recognition" + ], + "requiredActions": [ + "Move unearned revenue into deferred revenue until service is delivered." + ] + }, + { + "contractId": "contract:lab-annual-pro", + "obligationId": "obl:compute-credits", + "kind": "usage_credit", + "decision": "hold", + "allocatedCents": 600000, + "recognizedCents": 450000, + "allowedRecognizedCents": 120000, + "deferredCents": 480000, + "reasons": [ + "premature_recognition", + "unconsumed_usage_credits", + "missing_recognition_evidence" + ], + "requiredActions": [ + "Move unearned revenue into deferred revenue until service is delivered.", + "Recognize compute-credit revenue only as credits are consumed or expire.", + "Attach delivery, consumption, or export evidence for the recognized amount." + ] + }, + { + "contractId": "contract:consortium-license", + "obligationId": "obl:analytics-license-export", + "kind": "license_export", + "decision": "hold", + "allocatedCents": 900000, + "recognizedCents": 900000, + "allowedRecognizedCents": 29348, + "deferredCents": 870652, + "reasons": [ + "premature_recognition", + "missing_recognition_evidence" + ], + "requiredActions": [ + "Move unearned revenue into deferred revenue until service is delivered.", + "Attach delivery, consumption, or export evidence for the recognized amount." + ] + } + ], + "policy": { + "maxRoundingDriftCents": 2, + "requireEvidenceForRecognition": true, + "blockFutureServiceRecognition": true, + "blockUnconsumedUsageCredits": true, + "requireContractWindow": true, + "requireAllocationMatchesInvoice": true + } + }, + "reviewerChecklist": [ + "Invoice amount reconciles to performance obligation allocations.", + "Subscription revenue is deferred until the service period is earned.", + "Compute-credit revenue is recognized only on consumption or expiry.", + "Licensing/export revenue has delivery evidence before close.", + "Held obligations include deterministic remediation actions." + ] +} diff --git a/revenue-recognition-deferral-guard/reports/revenue-recognition-report.md b/revenue-recognition-deferral-guard/reports/revenue-recognition-report.md new file mode 100644 index 00000000..ff665d6d --- /dev/null +++ b/revenue-recognition-deferral-guard/reports/revenue-recognition-report.md @@ -0,0 +1,38 @@ +# Revenue Recognition Deferral Guard Report + +Issue: SCIBASE.AI#20 +Claim marker: `/claim #20` +Status: `block_close` +Digest: `8b6865927e5e430b33924f2853f0d9c0e7c005bb6002eabd6250d82d57c0ebe2` + +## Reviewer Checklist +- Invoice amount reconciles to performance obligation allocations. +- Subscription revenue is deferred until the service period is earned. +- Compute-credit revenue is recognized only on consumption or expiry. +- Licensing/export revenue has delivery evidence before close. +- Held obligations include deterministic remediation actions. + +## Totals +- Contracts inspected: 2 +- Obligations inspected: 3 +- Allowed recognized revenue: $9088.00 +- Deferred revenue: $23912.00 +- Held obligations: 3 +- Blockers: 6 +- Warnings: 0 + +## Blockers +- premature_revenue_recognition: Revenue is recognized before the obligation is sufficiently delivered. +- premature_revenue_recognition: Revenue is recognized before the obligation is sufficiently delivered. +- usage_credits_recognized_before_consumption: Usage credit revenue exceeds consumed or expired value. +- missing_recognition_evidence: Recognized revenue lacks required delivery evidence. +- premature_revenue_recognition: Revenue is recognized before the obligation is sufficiently delivered. +- missing_recognition_evidence: Recognized revenue lacks required delivery evidence. + +## Required Actions +- No finance actions required. + +## Obligation Decisions +- contract:lab-annual-pro/obl:annual-subscription: hold; recognized $18000.00, allowed $7594.52, deferred $10405.48 +- contract:lab-annual-pro/obl:compute-credits: hold; recognized $4500.00, allowed $1200.00, deferred $4800.00 +- contract:consortium-license/obl:analytics-license-export: hold; recognized $9000.00, allowed $293.48, deferred $8706.52 diff --git a/revenue-recognition-deferral-guard/reports/summary.svg b/revenue-recognition-deferral-guard/reports/summary.svg new file mode 100644 index 00000000..cfb01e17 --- /dev/null +++ b/revenue-recognition-deferral-guard/reports/summary.svg @@ -0,0 +1,18 @@ + + Revenue Recognition Deferral Guard Summary + SCIBASE issue 20 synthetic finance close guard summary. + + + Revenue Recognition Deferral Guard + Status: block_close | Digest: 8b6865927e5e430b + Allowed recognized: $9088.00 + + + Deferred revenue: $23912.00 + + + Held obligations: 3 + + + Synthetic contracts only. No processors, financial-account actions, tax filing, or external APIs. + diff --git a/revenue-recognition-deferral-guard/scripts/demo.js b/revenue-recognition-deferral-guard/scripts/demo.js new file mode 100644 index 00000000..1684ef27 --- /dev/null +++ b/revenue-recognition-deferral-guard/scripts/demo.js @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + buildReviewerPacket, + demoPacket, + renderMarkdownReport, + renderSvgSummary +} from "../src/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const moduleRoot = path.resolve(__dirname, ".."); +const reportsDir = path.join(moduleRoot, "reports"); +const packet = demoPacket(); +const reviewerPacket = buildReviewerPacket(packet, { asOf: packet.asOf }); + +fs.mkdirSync(reportsDir, { recursive: true }); +fs.writeFileSync(path.join(reportsDir, "revenue-recognition-packet.json"), `${JSON.stringify(reviewerPacket, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "revenue-recognition-report.md"), renderMarkdownReport(packet, { asOf: packet.asOf })); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(packet, { asOf: packet.asOf })); + +console.log(JSON.stringify({ + status: reviewerPacket.evaluation.status, + digest: reviewerPacket.evaluation.digest, + blockers: reviewerPacket.evaluation.totals.blockers, + deferredCents: reviewerPacket.evaluation.totals.deferredCents, + reportsDir +}, null, 2)); diff --git a/revenue-recognition-deferral-guard/src/index.js b/revenue-recognition-deferral-guard/src/index.js new file mode 100644 index 00000000..116f9ada --- /dev/null +++ b/revenue-recognition-deferral-guard/src/index.js @@ -0,0 +1,457 @@ +import crypto from "node:crypto"; + +const DEFAULT_POLICY = { + maxRoundingDriftCents: 2, + requireEvidenceForRecognition: true, + blockFutureServiceRecognition: true, + blockUnconsumedUsageCredits: true, + requireContractWindow: true, + requireAllocationMatchesInvoice: true +}; + +export function evaluateRevenueRecognitionPacket(packet, options = {}) { + const normalized = normalizePacket(packet); + const policy = { ...DEFAULT_POLICY, ...normalized.policy, ...(options.policy ?? {}) }; + const asOf = new Date(options.asOf ?? normalized.asOf); + const blockers = []; + const warnings = []; + const actions = []; + const obligationActions = []; + + for (const contract of normalized.contracts) { + const allocationSum = contract.obligations.reduce((sum, obligation) => sum + obligation.allocatedCents, 0); + const allocationDrift = Math.abs(allocationSum - contract.invoiceCents); + if (policy.requireAllocationMatchesInvoice && allocationDrift > policy.maxRoundingDriftCents) { + blockers.push({ + code: "allocation_does_not_match_invoice", + contractId: contract.id, + invoiceCents: contract.invoiceCents, + allocatedCents: allocationSum, + driftCents: allocationDrift, + message: "Performance obligation allocations do not reconcile with the invoice amount." + }); + actions.push({ + type: "reconcile_allocation", + contractId: contract.id, + reason: "Revenue cannot be recognized until invoice and obligation allocation agree." + }); + } + + for (const obligation of contract.obligations) { + const recognition = recognizeObligation(contract, obligation, normalized.usageEvents, asOf); + const recognizedTooEarly = recognition.recognizedCents > recognition.allowedRecognizedCents + policy.maxRoundingDriftCents; + const missingEvidence = + policy.requireEvidenceForRecognition && recognition.recognizedCents > 0 && recognition.evidenceState !== "complete"; + const futureService = + policy.blockFutureServiceRecognition && obligation.kind === "subscription" && recognition.serviceProgressRatio < 1; + const unconsumedCredits = + policy.blockUnconsumedUsageCredits && + obligation.kind === "usage_credit" && + recognition.allowedRecognizedCents + policy.maxRoundingDriftCents < recognition.recognizedCents; + + const action = { + contractId: contract.id, + obligationId: obligation.id, + kind: obligation.kind, + decision: "accept", + allocatedCents: obligation.allocatedCents, + recognizedCents: recognition.recognizedCents, + allowedRecognizedCents: recognition.allowedRecognizedCents, + deferredCents: Math.max(0, obligation.allocatedCents - recognition.allowedRecognizedCents), + reasons: [], + requiredActions: [] + }; + + if (policy.requireContractWindow && !obligation.startDate && obligation.kind !== "usage_credit") { + action.decision = "hold"; + action.reasons.push("missing_service_window"); + action.requiredActions.push("Add a service start and end date before recognition."); + blockers.push({ + code: "missing_service_window", + contractId: contract.id, + obligationId: obligation.id, + message: "Non-usage obligations need an explicit service period." + }); + } + + if (recognizedTooEarly || futureService) { + action.decision = "hold"; + action.reasons.push("premature_recognition"); + action.requiredActions.push("Move unearned revenue into deferred revenue until service is delivered."); + blockers.push({ + code: "premature_revenue_recognition", + contractId: contract.id, + obligationId: obligation.id, + recognizedCents: recognition.recognizedCents, + allowedRecognizedCents: recognition.allowedRecognizedCents, + message: "Revenue is recognized before the obligation is sufficiently delivered." + }); + } + + if (unconsumedCredits) { + action.decision = "hold"; + action.reasons.push("unconsumed_usage_credits"); + action.requiredActions.push("Recognize compute-credit revenue only as credits are consumed or expire."); + blockers.push({ + code: "usage_credits_recognized_before_consumption", + contractId: contract.id, + obligationId: obligation.id, + consumedCents: recognition.consumedCents, + recognizedCents: recognition.recognizedCents, + message: "Usage credit revenue exceeds consumed or expired value." + }); + } + + if (missingEvidence) { + action.decision = "hold"; + action.reasons.push("missing_recognition_evidence"); + action.requiredActions.push("Attach delivery, consumption, or export evidence for the recognized amount."); + blockers.push({ + code: "missing_recognition_evidence", + contractId: contract.id, + obligationId: obligation.id, + message: "Recognized revenue lacks required delivery evidence." + }); + } + + if (recognition.roundingDriftCents > policy.maxRoundingDriftCents) { + warnings.push({ + code: "recognition_rounding_drift", + contractId: contract.id, + obligationId: obligation.id, + driftCents: recognition.roundingDriftCents, + message: "Recognition schedule has rounding drift outside policy tolerance." + }); + } + + obligationActions.push(action); + } + } + + const acceptedRecognizedCents = obligationActions + .filter((action) => action.decision === "accept") + .reduce((sum, action) => sum + action.recognizedCents, 0); + const allowedRecognizedCents = obligationActions.reduce((sum, action) => sum + action.allowedRecognizedCents, 0); + const deferredCents = obligationActions.reduce((sum, action) => sum + action.deferredCents, 0); + const status = blockers.length ? "block_close" : warnings.length ? "needs_finance_review" : "close_ready"; + + return { + status, + asOf: asOf.toISOString(), + packetId: normalized.packetId, + digest: digest({ packetId: normalized.packetId, blockers, warnings, obligationActions }), + totals: { + contracts: normalized.contracts.length, + obligations: obligationActions.length, + acceptedRecognizedCents, + allowedRecognizedCents, + deferredCents, + blockers: blockers.length, + warnings: warnings.length, + heldObligations: obligationActions.filter((action) => action.decision === "hold").length + }, + blockers, + warnings, + actions, + obligationActions, + policy + }; +} + +export function recognizeObligation(contract, obligation, usageEvents, asOf) { + const recognizedCents = obligation.recognizedCents; + const evidenceState = obligation.evidence?.state ?? "missing"; + let allowedRecognizedCents = 0; + let consumedCents = 0; + let serviceProgressRatio = 0; + + if (obligation.kind === "subscription" || obligation.kind === "license_export") { + serviceProgressRatio = progressBetween(obligation.startDate, obligation.endDate, asOf); + allowedRecognizedCents = Math.round(obligation.allocatedCents * serviceProgressRatio); + } else if (obligation.kind === "usage_credit") { + consumedCents = usageEvents + .filter((event) => event.contractId === contract.id && event.obligationId === obligation.id) + .filter((event) => new Date(event.occurredAt) <= asOf) + .reduce((sum, event) => sum + event.consumedCents, 0); + const expired = obligation.expiresAt && new Date(obligation.expiresAt) <= asOf; + allowedRecognizedCents = expired ? obligation.allocatedCents : Math.min(obligation.allocatedCents, consumedCents); + serviceProgressRatio = obligation.allocatedCents ? allowedRecognizedCents / obligation.allocatedCents : 0; + } else { + throw new Error(`Unsupported obligation kind: ${obligation.kind}`); + } + + return { + recognizedCents, + allowedRecognizedCents, + consumedCents, + serviceProgressRatio, + evidenceState, + roundingDriftCents: Math.abs(Math.round(allowedRecognizedCents) - allowedRecognizedCents) + }; +} + +export function buildReviewerPacket(packet, options = {}) { + const evaluation = evaluateRevenueRecognitionPacket(packet, options); + return { + title: "SCIBASE Revenue Recognition Deferral Guard", + issue: "SCIBASE.AI#20", + claim: "/claim #20", + purpose: + "Prevent revenue close packets from recognizing subscription, compute-credit, or licensing revenue before delivery evidence exists.", + evaluation, + reviewerChecklist: [ + "Invoice amount reconciles to performance obligation allocations.", + "Subscription revenue is deferred until the service period is earned.", + "Compute-credit revenue is recognized only on consumption or expiry.", + "Licensing/export revenue has delivery evidence before close.", + "Held obligations include deterministic remediation actions." + ] + }; +} + +export function renderMarkdownReport(packet, options = {}) { + const review = buildReviewerPacket(packet, options); + const { evaluation } = review; + const lines = [ + "# Revenue Recognition Deferral Guard Report", + "", + `Issue: ${review.issue}`, + `Claim marker: \`${review.claim}\``, + `Status: \`${evaluation.status}\``, + `Digest: \`${evaluation.digest}\``, + "", + "## Reviewer Checklist", + ...review.reviewerChecklist.map((item) => `- ${item}`), + "", + "## Totals", + `- Contracts inspected: ${evaluation.totals.contracts}`, + `- Obligations inspected: ${evaluation.totals.obligations}`, + `- Allowed recognized revenue: ${formatCents(evaluation.totals.allowedRecognizedCents)}`, + `- Deferred revenue: ${formatCents(evaluation.totals.deferredCents)}`, + `- Held obligations: ${evaluation.totals.heldObligations}`, + `- Blockers: ${evaluation.totals.blockers}`, + `- Warnings: ${evaluation.totals.warnings}`, + "", + "## Blockers", + ...formatFindings(evaluation.blockers), + "", + "## Required Actions", + ...(evaluation.actions.length + ? evaluation.actions.map((action) => `- ${action.type}: ${action.reason}`) + : ["- No finance actions required."]), + "", + "## Obligation Decisions", + ...evaluation.obligationActions.map( + (action) => + `- ${action.contractId}/${action.obligationId}: ${action.decision}; recognized ${formatCents(action.recognizedCents)}, allowed ${formatCents(action.allowedRecognizedCents)}, deferred ${formatCents(action.deferredCents)}` + ) + ]; + return `${lines.join("\n")}\n`; +} + +export function renderSvgSummary(packet, options = {}) { + const review = buildReviewerPacket(packet, options); + const { evaluation } = review; + const max = Math.max( + evaluation.totals.allowedRecognizedCents, + evaluation.totals.deferredCents, + evaluation.totals.heldObligations * 100_000, + 1 + ); + return ` + Revenue Recognition Deferral Guard Summary + SCIBASE issue 20 synthetic finance close guard summary. + + + Revenue Recognition Deferral Guard + Status: ${escapeXml(evaluation.status)} | Digest: ${escapeXml(evaluation.digest.slice(0, 16))} + ${svgMetric("Allowed recognized", formatCents(evaluation.totals.allowedRecognizedCents), evaluation.totals.allowedRecognizedCents, max, 56, 150, "#16a34a")} + ${svgMetric("Deferred revenue", formatCents(evaluation.totals.deferredCents), evaluation.totals.deferredCents, max, 56, 205, "#2563eb")} + ${svgMetric("Held obligations", String(evaluation.totals.heldObligations), evaluation.totals.heldObligations * 100_000, max, 56, 260, "#dc2626")} + Synthetic contracts only. No processors, financial-account actions, tax filing, or external APIs. + +`; +} + +export function demoPacket() { + return { + packetId: "scibase-revenue-recognition-close-2026-06", + asOf: "2026-06-04T00:00:00.000Z", + policy: { + maxRoundingDriftCents: 2 + }, + contracts: [ + { + id: "contract:lab-annual-pro", + customerType: "lab", + invoiceCents: 2400000, + currency: "USD", + obligations: [ + { + id: "obl:annual-subscription", + kind: "subscription", + allocatedCents: 1800000, + recognizedCents: 1800000, + startDate: "2026-01-01T00:00:00.000Z", + endDate: "2026-12-31T23:59:59.000Z", + evidence: { state: "complete", reference: "synthetic-service-ledger" } + }, + { + id: "obl:compute-credits", + kind: "usage_credit", + allocatedCents: 600000, + recognizedCents: 450000, + expiresAt: "2026-12-31T23:59:59.000Z", + evidence: { state: "partial", reference: "synthetic-meter-events" } + } + ] + }, + { + id: "contract:consortium-license", + customerType: "institution", + invoiceCents: 900000, + currency: "USD", + obligations: [ + { + id: "obl:analytics-license-export", + kind: "license_export", + allocatedCents: 900000, + recognizedCents: 900000, + startDate: "2026-06-01T00:00:00.000Z", + endDate: "2026-08-31T23:59:59.000Z", + evidence: { state: "missing", reference: null } + } + ] + } + ], + usageEvents: [ + { + contractId: "contract:lab-annual-pro", + obligationId: "obl:compute-credits", + occurredAt: "2026-05-20T00:00:00.000Z", + consumedCents: 120000, + source: "synthetic-ai-meter" + } + ] + }; +} + +export function normalizePacket(packet) { + if (!packet || typeof packet !== "object") { + throw new TypeError("A revenue recognition packet object is required."); + } + return { + packetId: nonEmpty(packet.packetId, "packetId"), + asOf: nonEmpty(packet.asOf, "asOf"), + policy: packet.policy ?? {}, + contracts: ensureArray(packet.contracts, "contracts").map(normalizeContract), + usageEvents: ensureArray(packet.usageEvents ?? [], "usageEvents").map(normalizeUsageEvent) + }; +} + +function normalizeContract(contract) { + const invoiceCents = cents(contract.invoiceCents, "contract.invoiceCents"); + if (invoiceCents <= 0) { + throw new Error("contract.invoiceCents must be positive."); + } + return { + id: nonEmpty(contract.id, "contract.id"), + customerType: contract.customerType ?? "unknown", + invoiceCents, + currency: contract.currency ?? "USD", + obligations: ensureArray(contract.obligations, "contract.obligations").map(normalizeObligation) + }; +} + +function normalizeObligation(obligation) { + return { + id: nonEmpty(obligation.id, "obligation.id"), + kind: nonEmpty(obligation.kind, "obligation.kind"), + allocatedCents: cents(obligation.allocatedCents, "obligation.allocatedCents"), + recognizedCents: cents(obligation.recognizedCents ?? 0, "obligation.recognizedCents"), + startDate: obligation.startDate, + endDate: obligation.endDate, + expiresAt: obligation.expiresAt, + evidence: obligation.evidence ?? { state: "missing" } + }; +} + +function normalizeUsageEvent(event) { + return { + contractId: nonEmpty(event.contractId, "usageEvent.contractId"), + obligationId: nonEmpty(event.obligationId, "usageEvent.obligationId"), + occurredAt: nonEmpty(event.occurredAt, "usageEvent.occurredAt"), + consumedCents: cents(event.consumedCents, "usageEvent.consumedCents"), + source: event.source ?? "synthetic" + }; +} + +function progressBetween(startDate, endDate, asOf) { + if (!startDate || !endDate) { + return 0; + } + const start = new Date(startDate); + const end = new Date(endDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) { + return 0; + } + if (asOf <= start) { + return 0; + } + if (asOf >= end) { + return 1; + } + return (asOf.getTime() - start.getTime()) / (end.getTime() - start.getTime()); +} + +function cents(value, name) { + const amount = Number(value); + if (!Number.isInteger(amount) || amount < 0) { + throw new TypeError(`${name} must be a non-negative integer cent amount.`); + } + return amount; +} + +function ensureArray(value, name) { + if (!Array.isArray(value)) { + throw new TypeError(`${name} must be an array.`); + } + return value; +} + +function nonEmpty(value, name) { + if (typeof value !== "string" || !value.trim()) { + throw new TypeError(`${name} must be a non-empty string.`); + } + return value.trim(); +} + +function digest(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function formatFindings(findings) { + if (!findings.length) { + return ["- None."]; + } + return findings.map((finding) => `- ${finding.code}: ${finding.message}`); +} + +function formatCents(centsValue) { + return `$${(centsValue / 100).toFixed(2)}`; +} + +function svgMetric(label, displayValue, value, max, x, y, color) { + const width = Math.max(10, Math.round((value / max) * 340)); + return `${escapeXml(label)}: ${escapeXml(displayValue)} + + `; +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); +} diff --git a/revenue-recognition-deferral-guard/test/index.test.js b/revenue-recognition-deferral-guard/test/index.test.js new file mode 100644 index 00000000..a830d60f --- /dev/null +++ b/revenue-recognition-deferral-guard/test/index.test.js @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildReviewerPacket, + demoPacket, + evaluateRevenueRecognitionPacket, + normalizePacket, + recognizeObligation, + renderMarkdownReport, + renderSvgSummary +} from "../src/index.js"; + +test("blocks premature subscription revenue recognition", () => { + const result = evaluateRevenueRecognitionPacket(demoPacket(), { + asOf: "2026-06-04T00:00:00.000Z" + }); + + assert.equal(result.status, "block_close"); + assert.ok(result.blockers.some((blocker) => blocker.code === "premature_revenue_recognition")); + assert.ok( + result.obligationActions.some( + (action) => action.obligationId === "obl:annual-subscription" && action.decision === "hold" + ) + ); +}); + +test("blocks unconsumed compute credits", () => { + const result = evaluateRevenueRecognitionPacket(demoPacket(), { + asOf: "2026-06-04T00:00:00.000Z" + }); + + assert.ok(result.blockers.some((blocker) => blocker.code === "usage_credits_recognized_before_consumption")); + const usageAction = result.obligationActions.find((action) => action.obligationId === "obl:compute-credits"); + assert.equal(usageAction.decision, "hold"); + assert.equal(usageAction.allowedRecognizedCents, 120000); +}); + +test("blocks missing delivery evidence for recognized license revenue", () => { + const result = evaluateRevenueRecognitionPacket(demoPacket(), { + asOf: "2026-06-04T00:00:00.000Z" + }); + + assert.ok(result.blockers.some((blocker) => blocker.code === "missing_recognition_evidence")); + assert.ok( + result.obligationActions.some( + (action) => action.obligationId === "obl:analytics-license-export" && action.reasons.includes("missing_recognition_evidence") + ) + ); +}); + +test("accepts a clean fully delivered synthetic close packet", () => { + const packet = demoPacket(); + packet.asOf = "2027-01-02T00:00:00.000Z"; + packet.contracts[0].obligations[0].recognizedCents = 1800000; + packet.contracts[0].obligations[1].recognizedCents = 600000; + packet.contracts[0].obligations[1].expiresAt = "2026-12-31T23:59:59.000Z"; + packet.contracts[0].obligations[1].evidence = { state: "complete", reference: "synthetic-expiry-ledger" }; + packet.contracts[1].obligations[0].evidence = { state: "complete", reference: "synthetic-export-receipt" }; + + const result = evaluateRevenueRecognitionPacket(packet, { + asOf: "2027-01-02T00:00:00.000Z" + }); + + assert.equal(result.status, "close_ready"); + assert.equal(result.totals.blockers, 0); + assert.equal(result.totals.heldObligations, 0); +}); + +test("normalizes packets and rejects bad cent amounts", () => { + assert.equal(normalizePacket(demoPacket()).contracts.length, 2); + const broken = demoPacket(); + broken.contracts[0].invoiceCents = 23.4; + assert.throws(() => normalizePacket(broken), /integer cent amount/); +}); + +test("recognizes subscription progress by service window", () => { + const packet = normalizePacket(demoPacket()); + const contract = packet.contracts[0]; + const obligation = contract.obligations[0]; + const recognition = recognizeObligation(contract, obligation, packet.usageEvents, new Date("2026-07-01T00:00:00.000Z")); + + assert.ok(recognition.serviceProgressRatio > 0.49); + assert.ok(recognition.allowedRecognizedCents < obligation.allocatedCents); +}); + +test("produces reviewer artifacts with claim marker", () => { + const packet = demoPacket(); + const review = buildReviewerPacket(packet, { asOf: packet.asOf }); + const markdown = renderMarkdownReport(packet, { asOf: packet.asOf }); + const svg = renderSvgSummary(packet, { asOf: packet.asOf }); + + assert.equal(review.claim, "/claim #20"); + assert.match(markdown, /Revenue Recognition Deferral Guard Report/); + assert.match(markdown, /`\/claim #20`/); + assert.match(svg, /