diff --git a/revenue-recognition-close/README.md b/revenue-recognition-close/README.md new file mode 100644 index 0000000..c87de1a --- /dev/null +++ b/revenue-recognition-close/README.md @@ -0,0 +1,42 @@ +# Revenue Recognition Close + +Dependency-free finance-close controls for the SCIBASE revenue infrastructure bounty. + +This module focuses on what happens after subscription, compute, and licensing systems emit invoice and delivery evidence. It builds a deterministic close packet that answers: + +- how much revenue is earned in the current period +- how much remains deferred +- which balances are still receivable +- which overdue invoices need dunning +- which credit, refund, or collection risks should hold close certification + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +## Demo Output + +```text +Close: scibase-apr-2026-close +Status: attention-needed +Recognized: US$7,936.30 +Deferred after close: US$13,304.79 +Receivable: US$12,200.00 +Dunning queue: 1 +Close holds: 2 +Top action: finance-review for INV-2026-0391 +``` + +## Files + +- `src/revenue-close.js` builds the close packet, journal entries, dunning queue, holds, dashboard, audit trail, and stable digest. +- `data/sample-close.json` contains synthetic subscription, compute, and licensing contracts. +- `test/revenue-close.test.js` verifies recognition, deferral, receivable, dunning, hold, and digest behavior. +- `docs/requirement-map.md` maps the slice to issue #20. +- `docs/demo.svg` and `docs/demo.mp4` provide a short visual artifact for review. + +No real payment processor, customer secret, private research content, or external API credential is used. diff --git a/revenue-recognition-close/data/sample-close.json b/revenue-recognition-close/data/sample-close.json new file mode 100644 index 0000000..ef04c59 --- /dev/null +++ b/revenue-recognition-close/data/sample-close.json @@ -0,0 +1,115 @@ +{ + "closeId": "scibase-apr-2026-close", + "asOf": "2026-04-30T23:59:59Z", + "periodStart": "2026-04-01", + "periodEnd": "2026-05-01", + "currency": "USD", + "policies": { + "paymentGraceDays": 5, + "dunningSteps": [ + { + "afterDays": 5, + "action": "send-reminder", + "owner": "revenue-ops" + }, + { + "afterDays": 15, + "action": "finance-review", + "owner": "finance" + }, + { + "afterDays": 30, + "action": "pause-api-access", + "owner": "customer-success" + } + ], + "recognitionTolerance": 0.01 + }, + "contracts": [ + { + "id": "sub-lab-annual", + "customer": "Northstar Systems Lab", + "stream": "subscription", + "invoiceId": "INV-2026-0410", + "invoiceDate": "2026-01-01", + "dueDate": "2026-01-15", + "paidAt": "2026-01-06T14:20:00Z", + "amount": 12000, + "serviceStart": "2026-01-01", + "serviceEnd": "2027-01-01", + "notes": "Annual lab subscription with ratable recognition." + }, + { + "id": "compute-foundation-burst", + "customer": "Helix BioCompute Group", + "stream": "compute", + "invoiceId": "INV-2026-0444", + "invoiceDate": "2026-04-20", + "dueDate": "2026-05-10", + "paidAt": null, + "amount": 3200, + "serviceStart": "2026-04-01", + "serviceEnd": "2026-05-01", + "usageEvents": [ + { + "id": "gpu-job-7781", + "occurredAt": "2026-04-09T11:00:00Z", + "units": 42, + "unitPrice": 20, + "billable": true + }, + { + "id": "repro-run-9032", + "occurredAt": "2026-04-27T18:45:00Z", + "units": 37, + "unitPrice": 30, + "billable": true + }, + { + "id": "may-training-001", + "occurredAt": "2026-05-02T08:00:00Z", + "units": 50, + "unitPrice": 25, + "billable": true + } + ], + "notes": "Usage-based compute invoice with unconsumed April capacity deferred." + }, + { + "id": "license-policy-api", + "customer": "Public Research Policy Office", + "stream": "license", + "invoiceId": "INV-2026-0391", + "invoiceDate": "2026-03-25", + "dueDate": "2026-04-15", + "paidAt": null, + "amount": 9000, + "serviceStart": "2026-04-01", + "serviceEnd": "2026-06-30", + "licenseDeliverables": [ + { + "id": "citation-network-april", + "deliveredAt": "2026-04-10T10:30:00Z", + "amount": 5000, + "accepted": true + }, + { + "id": "grant-trend-dashboard", + "deliveredAt": "2026-04-28T16:00:00Z", + "amount": 4000, + "accepted": false + } + ], + "credits": [ + { + "id": "credit-redaction-review", + "amount": 500, + "requestedAt": "2026-04-29T12:00:00Z", + "status": "pending", + "reason": "Customer asked finance to review a delayed redaction memo." + } + ], + "notes": "Analytics licensing package with partial acceptance and an open credit review." + } + ] +} diff --git a/revenue-recognition-close/docs/demo.mp4 b/revenue-recognition-close/docs/demo.mp4 new file mode 100644 index 0000000..ba0fa0f Binary files /dev/null and b/revenue-recognition-close/docs/demo.mp4 differ diff --git a/revenue-recognition-close/docs/demo.svg b/revenue-recognition-close/docs/demo.svg new file mode 100644 index 0000000..64836bb --- /dev/null +++ b/revenue-recognition-close/docs/demo.svg @@ -0,0 +1,29 @@ + + Revenue recognition close demo + Dashboard-style summary of recognized revenue, deferred revenue, receivables, dunning, and close holds. + + + SCIBASE Revenue Close + April 2026 recognition, deferral, receivable, and dunning controls + + + Recognized + $7,936.30 + + + + Deferred + $13,304.79 + + + + Receivable + $12,200.00 + + + + Top close action + finance-review for INV-2026-0391 + 1 dunning item · 2 close holds · digest-backed audit packet + + diff --git a/revenue-recognition-close/docs/requirement-map.md b/revenue-recognition-close/docs/requirement-map.md new file mode 100644 index 0000000..a6882a5 --- /dev/null +++ b/revenue-recognition-close/docs/requirement-map.md @@ -0,0 +1,30 @@ +# Requirement Map + +This module contributes a focused month-end revenue recognition and collection-control slice for issue #20. + +| Issue area | Covered by this module | +| --- | --- | +| Tiered subscription billing | Ratable recognition for annual subscription service periods, deferred-revenue carry-forward, collected cash tracking | +| AI compute billing | Usage-event recognition inside the close window, deferred unused invoice capacity, receivable tracking | +| Licensing APIs & analytics | Accepted-deliverable recognition for analytics licensing packages, deferred undelivered value, partial acceptance evidence | +| Institutional invoicing | Invoice due-date aging, accounts receivable at close, finance-owned dunning escalation | +| Revenue operations | Journal-entry packet, audit trail, close certification dashboard, credit/refund/collection holds | + +## Distinctness + +Existing submissions for #20 cover billing engines, entitlement decisions, metering ledgers, procurement controls, anomaly reconciliation, and privacy-safe analytics licensing gates. This module focuses on the finance close boundary after those systems emit invoices and usage/deliverable evidence: + +- How much revenue is earned in the current close period +- What remains deferred after the period +- Which invoices remain receivable +- Which overdue accounts need dunning +- Which credit/refund/collectability risks should block close certification + +## Verification + +```bash +cd revenue-recognition-close +npm run check +npm test +npm run demo +``` diff --git a/revenue-recognition-close/package.json b/revenue-recognition-close/package.json new file mode 100644 index 0000000..9e71842 --- /dev/null +++ b/revenue-recognition-close/package.json @@ -0,0 +1,18 @@ +{ + "name": "revenue-recognition-close", + "version": "1.0.0", + "private": true, + "description": "Dependency-free revenue recognition close controls for SCIBASE revenue infrastructure.", + "scripts": { + "check": "node --check src/revenue-close.js && node --check scripts/demo.js && node --check test/revenue-close.test.js", + "demo": "node scripts/demo.js", + "test": "node test/revenue-close.test.js" + }, + "keywords": [ + "revenue-recognition", + "deferred-revenue", + "dunning", + "finance-close" + ], + "license": "MIT" +} diff --git a/revenue-recognition-close/scripts/demo.js b/revenue-recognition-close/scripts/demo.js new file mode 100644 index 0000000..d69cf9e --- /dev/null +++ b/revenue-recognition-close/scripts/demo.js @@ -0,0 +1,17 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { buildRevenueClose } = require("../src/revenue-close"); + +const samplePath = path.join(__dirname, "..", "data", "sample-close.json"); +const closeInput = JSON.parse(fs.readFileSync(samplePath, "utf8")); +const close = buildRevenueClose(closeInput); + +console.log(`Close: ${close.closeId}`); +console.log(`Status: ${close.dashboard.status}`); +console.log(`Recognized: US$${close.totals.recognizedThisPeriod.toLocaleString("en-US", { minimumFractionDigits: 2 })}`); +console.log(`Deferred after close: US$${close.totals.deferredAfterClose.toLocaleString("en-US", { minimumFractionDigits: 2 })}`); +console.log(`Receivable: US$${close.totals.receivableAtClose.toLocaleString("en-US", { minimumFractionDigits: 2 })}`); +console.log(`Dunning queue: ${close.dunningQueue.length}`); +console.log(`Close holds: ${close.creditAndRefundHolds.length}`); +console.log(`Top action: ${close.dashboard.highPriority[0].nextAction}`); +console.log(`Digest: ${close.digest}`); diff --git a/revenue-recognition-close/src/revenue-close.js b/revenue-recognition-close/src/revenue-close.js new file mode 100644 index 0000000..0585aee --- /dev/null +++ b/revenue-recognition-close/src/revenue-close.js @@ -0,0 +1,425 @@ +const crypto = require("node:crypto"); + +function buildRevenueClose(input) { + const validation = validateCloseInput(input); + const period = { + start: parseDate(input.periodStart), + end: parseDate(input.periodEnd), + asOf: parseDateTime(input.asOf || input.periodEnd) + }; + const policies = normalizePolicies(input.policies || {}); + const contracts = (input.contracts || []).map((contract) => + closeContract(contract, period, policies, input.currency || "USD") + ); + const totals = summarizeTotals(contracts); + const dunningQueue = contracts.filter((contract) => contract.dunning.status === "queued"); + const creditAndRefundHolds = contracts.flatMap((contract) => contract.holds); + const journalEntries = buildJournalEntries(input, contracts, totals); + const auditTrail = buildAuditTrail(input, contracts, totals, dunningQueue, creditAndRefundHolds); + const dashboard = buildDashboard(input, contracts, totals, dunningQueue, creditAndRefundHolds); + + const close = { + closeId: input.closeId, + periodStart: input.periodStart, + periodEnd: input.periodEnd, + asOf: input.asOf, + currency: input.currency || "USD", + validation, + totals, + contracts, + dunningQueue, + creditAndRefundHolds, + journalEntries, + dashboard, + auditTrail + }; + + close.digest = stableDigest(close); + return close; +} + +function validateCloseInput(input) { + const required = [ + ["closeId", input.closeId], + ["periodStart", input.periodStart], + ["periodEnd", input.periodEnd], + ["contracts", (input.contracts || []).length] + ]; + const missing = required.filter(([, value]) => !value).map(([field]) => field); + const contractIssues = (input.contracts || []).flatMap((contract) => { + const issues = []; + if (!contract.id) issues.push("contract.id"); + if (!contract.customer) issues.push(`${contract.id || "unknown"}.customer`); + if (!["subscription", "compute", "license"].includes(contract.stream)) { + issues.push(`${contract.id || "unknown"}.stream`); + } + if (typeof contract.amount !== "number" || contract.amount < 0) { + issues.push(`${contract.id || "unknown"}.amount`); + } + if (!contract.serviceStart || !contract.serviceEnd) { + issues.push(`${contract.id || "unknown"}.service-period`); + } + return issues; + }); + + return { + status: missing.length === 0 && contractIssues.length === 0 ? "passed" : "incomplete", + score: Math.max(0, 100 - missing.length * 15 - contractIssues.length * 5), + missing, + contractIssues + }; +} + +function closeContract(contract, period, policies, currency) { + const serviceStart = parseDate(contract.serviceStart); + const serviceEnd = parseDate(contract.serviceEnd); + const netAmount = money(contract.amount - approvedAdjustments(contract)); + const revenue = recognizeRevenue(contract, period, serviceStart, serviceEnd, netAmount); + const payment = evaluatePayment(contract, period, netAmount); + const dunning = evaluateDunning(contract, payment, period, policies); + const holds = buildHolds(contract, revenue, payment, dunning, currency); + + return { + id: contract.id, + customer: contract.customer, + stream: contract.stream, + invoiceId: contract.invoiceId, + netAmount, + revenue, + payment, + dunning, + holds, + closeStatus: chooseCloseStatus(revenue, payment, dunning, holds) + }; +} + +function recognizeRevenue(contract, period, serviceStart, serviceEnd, netAmount) { + if (contract.stream === "subscription") { + return recognizeRatable(contract, period, serviceStart, serviceEnd, netAmount); + } + if (contract.stream === "compute") { + return recognizeCompute(contract, period, netAmount); + } + return recognizeLicense(contract, period, netAmount); +} + +function recognizeRatable(contract, period, serviceStart, serviceEnd, netAmount) { + const serviceDays = Math.max(1, dayDiff(serviceStart, serviceEnd)); + const periodEarnedDays = overlapDays(serviceStart, serviceEnd, period.start, period.end); + const earnedThroughCloseDays = overlapDays(serviceStart, serviceEnd, serviceStart, period.end); + const recognizedThisPeriod = money(netAmount * (periodEarnedDays / serviceDays)); + const recognizedToDate = money(netAmount * (earnedThroughCloseDays / serviceDays)); + const deferredAfterClose = money(Math.max(0, netAmount - recognizedToDate)); + + return { + method: "ratable-service-period", + recognizedThisPeriod, + recognizedToDate, + deferredAfterClose, + evidence: [`${periodEarnedDays} of ${serviceDays} service days earned in close period`] + }; +} + +function recognizeCompute(contract, period, netAmount) { + const billableEvents = (contract.usageEvents || []).filter((event) => + event.billable && isInPeriod(parseDateTime(event.occurredAt), period.start, period.end) + ); + const recognizedThisPeriod = money( + billableEvents.reduce((sum, event) => sum + event.units * event.unitPrice, 0) + ); + const cappedRecognized = Math.min(recognizedThisPeriod, netAmount); + const deferredAfterClose = money(Math.max(0, netAmount - cappedRecognized)); + + return { + method: "billable-usage-events", + recognizedThisPeriod: cappedRecognized, + recognizedToDate: cappedRecognized, + deferredAfterClose, + evidence: billableEvents.map((event) => `${event.id}: ${event.units} units x ${formatMoney(event.unitPrice)}`) + }; +} + +function recognizeLicense(contract, period, netAmount) { + const acceptedDeliverables = (contract.licenseDeliverables || []).filter((deliverable) => + deliverable.accepted && isInPeriod(parseDateTime(deliverable.deliveredAt), period.start, period.end) + ); + const recognizedThisPeriod = money( + Math.min(netAmount, acceptedDeliverables.reduce((sum, item) => sum + item.amount, 0)) + ); + const deferredAfterClose = money(Math.max(0, netAmount - recognizedThisPeriod)); + + return { + method: "accepted-license-deliverables", + recognizedThisPeriod, + recognizedToDate: recognizedThisPeriod, + deferredAfterClose, + evidence: acceptedDeliverables.map((item) => `${item.id}: accepted deliverable`) + }; +} + +function evaluatePayment(contract, period, netAmount) { + const paidAt = contract.paidAt ? parseDateTime(contract.paidAt) : null; + const paidByClose = Boolean(paidAt && paidAt <= period.asOf); + const receivableAtClose = paidByClose ? 0 : netAmount; + const dueDate = contract.dueDate ? parseDate(contract.dueDate) : null; + const daysPastDue = dueDate && !paidByClose + ? Math.max(0, Math.floor((period.asOf - dueDate) / 864e5)) + : 0; + + return { + status: paidByClose ? "collected" : "open-receivable", + paidAt: contract.paidAt || null, + receivableAtClose, + daysPastDue + }; +} + +function evaluateDunning(contract, payment, period, policies) { + if (payment.status === "collected" || payment.daysPastDue < policies.paymentGraceDays) { + return { + status: "not-needed", + daysPastDue: payment.daysPastDue, + action: null, + owner: null + }; + } + + const step = policies.dunningSteps + .filter((candidate) => payment.daysPastDue >= candidate.afterDays) + .sort((a, b) => b.afterDays - a.afterDays)[0]; + + return { + status: "queued", + daysPastDue: payment.daysPastDue, + action: step ? step.action : "manual-review", + owner: step ? step.owner : "finance" + }; +} + +function buildHolds(contract, revenue, payment, dunning, currency) { + const pendingCredits = (contract.credits || []).filter((credit) => credit.status === "pending"); + const pendingRefunds = (contract.refunds || []).filter((refund) => refund.status === "pending"); + const holds = []; + + for (const credit of pendingCredits) { + holds.push({ + contractId: contract.id, + type: "credit-review", + amount: credit.amount, + currency, + reason: credit.reason, + recommendedAction: "Resolve credit memo request before final close certification." + }); + } + for (const refund of pendingRefunds) { + holds.push({ + contractId: contract.id, + type: "refund-review", + amount: refund.amount, + currency, + reason: refund.reason, + recommendedAction: "Hold revenue release until refund approval is decided." + }); + } + if (dunning.status === "queued" && revenue.recognizedThisPeriod > 0) { + holds.push({ + contractId: contract.id, + type: "collection-risk", + amount: payment.receivableAtClose, + currency, + reason: `${contract.invoiceId || contract.id} is ${payment.daysPastDue} days past due.`, + recommendedAction: "Finance should certify collectability or reserve the balance." + }); + } + return holds; +} + +function summarizeTotals(contracts) { + return { + recognizedThisPeriod: money(sum(contracts, (contract) => contract.revenue.recognizedThisPeriod)), + deferredAfterClose: money(sum(contracts, (contract) => contract.revenue.deferredAfterClose)), + receivableAtClose: money(sum(contracts, (contract) => contract.payment.receivableAtClose)), + cashCollected: money(sum(contracts, (contract) => contract.payment.status === "collected" ? contract.netAmount : 0)), + contractsClosed: contracts.filter((contract) => contract.closeStatus === "closed").length, + contractsNeedingAttention: contracts.filter((contract) => contract.closeStatus !== "closed").length + }; +} + +function buildJournalEntries(input, contracts, totals) { + const entries = []; + if (totals.recognizedThisPeriod > 0) { + entries.push({ + id: `${input.closeId}-revenue`, + debit: "deferred_revenue_or_accounts_receivable", + credit: "recognized_revenue", + amount: totals.recognizedThisPeriod, + memo: "Recognize earned subscription, compute, and license revenue for the close period." + }); + } + if (totals.deferredAfterClose > 0) { + entries.push({ + id: `${input.closeId}-deferred`, + debit: "cash_or_accounts_receivable", + credit: "deferred_revenue", + amount: totals.deferredAfterClose, + memo: "Carry unearned service and undelivered license value after the close." + }); + } + for (const contract of contracts.filter((item) => item.dunning.status === "queued")) { + entries.push({ + id: `${input.closeId}-${contract.id}-dunning`, + debit: "collections_queue", + credit: "accounts_receivable_monitoring", + amount: contract.payment.receivableAtClose, + memo: `${contract.customer} queued for ${contract.dunning.action}.` + }); + } + return entries; +} + +function buildAuditTrail(input, contracts, totals, dunningQueue, holds) { + const events = [ + { + type: "close-built", + closeId: input.closeId, + contractCount: contracts.length, + recognizedThisPeriod: totals.recognizedThisPeriod + } + ]; + for (const contract of contracts) { + events.push({ + type: "contract-closed", + contractId: contract.id, + stream: contract.stream, + status: contract.closeStatus, + method: contract.revenue.method, + recognizedThisPeriod: contract.revenue.recognizedThisPeriod + }); + } + for (const item of dunningQueue) { + events.push({ + type: "dunning-queued", + contractId: item.id, + action: item.dunning.action, + daysPastDue: item.dunning.daysPastDue + }); + } + for (const hold of holds) { + events.push({ + type: "close-hold", + contractId: hold.contractId, + holdType: hold.type, + amount: hold.amount + }); + } + return events; +} + +function buildDashboard(input, contracts, totals, dunningQueue, holds) { + const highPriority = [ + ...dunningQueue.map((contract) => ({ + contractId: contract.id, + customer: contract.customer, + owner: contract.dunning.owner, + nextAction: `${contract.dunning.action} for ${contract.invoiceId || contract.id}` + })), + ...holds.map((hold) => ({ + contractId: hold.contractId, + customer: contractCustomer(contracts, hold.contractId), + owner: "finance", + nextAction: hold.recommendedAction + })) + ]; + + return { + title: `Revenue close ${input.closeId}`, + status: highPriority.length === 0 ? "ready-to-certify" : "attention-needed", + recognizedThisPeriod: totals.recognizedThisPeriod, + receivableAtClose: totals.receivableAtClose, + deferredAfterClose: totals.deferredAfterClose, + dunningCount: dunningQueue.length, + holdCount: holds.length, + highPriority + }; +} + +function chooseCloseStatus(revenue, payment, dunning, holds) { + if (holds.length > 0 || dunning.status === "queued") return "attention-needed"; + if (revenue.evidence.length === 0) return "no-period-evidence"; + if (payment.status === "open-receivable") return "closed-with-receivable"; + return "closed"; +} + +function approvedAdjustments(contract) { + const credits = (contract.credits || []).filter((credit) => credit.status === "approved"); + const refunds = (contract.refunds || []).filter((refund) => refund.status === "approved"); + return [...credits, ...refunds].reduce((sumValue, item) => sumValue + item.amount, 0); +} + +function normalizePolicies(policies) { + return { + paymentGraceDays: Number.isFinite(policies.paymentGraceDays) ? policies.paymentGraceDays : 7, + dunningSteps: Array.isArray(policies.dunningSteps) ? policies.dunningSteps : [], + recognitionTolerance: Number.isFinite(policies.recognitionTolerance) ? policies.recognitionTolerance : 0.01 + }; +} + +function parseDate(value) { + return new Date(`${value}T00:00:00Z`); +} + +function parseDateTime(value) { + return new Date(value); +} + +function isInPeriod(value, start, end) { + return value >= start && value < end; +} + +function overlapDays(startA, endA, startB, endB) { + const start = new Date(Math.max(startA.getTime(), startB.getTime())); + const end = new Date(Math.min(endA.getTime(), endB.getTime())); + return Math.max(0, dayDiff(start, end)); +} + +function dayDiff(start, end) { + return Math.round((end - start) / 864e5); +} + +function money(value) { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function formatMoney(value) { + return `$${money(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function sum(items, select) { + return items.reduce((total, item) => total + select(item), 0); +} + +function contractCustomer(contracts, contractId) { + const contract = contracts.find((item) => item.id === contractId); + return contract ? contract.customer : "unknown"; +} + +function stableDigest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +module.exports = { + buildRevenueClose, + validateCloseInput, + closeContract, + stableDigest +}; diff --git a/revenue-recognition-close/test/revenue-close.test.js b/revenue-recognition-close/test/revenue-close.test.js new file mode 100644 index 0000000..d04c237 --- /dev/null +++ b/revenue-recognition-close/test/revenue-close.test.js @@ -0,0 +1,54 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { buildRevenueClose, validateCloseInput } = require("../src/revenue-close"); + +const samplePath = path.join(__dirname, "..", "data", "sample-close.json"); +const closeInput = JSON.parse(fs.readFileSync(samplePath, "utf8")); +const close = buildRevenueClose(closeInput); + +assert.equal(close.validation.status, "passed"); +assert.equal(close.contracts.length, 3); +assert.equal(close.totals.recognizedThisPeriod, 7936.3); +assert.equal(close.totals.deferredAfterClose, 13304.79); +assert.equal(close.totals.receivableAtClose, 12200); +assert.equal(close.totals.cashCollected, 12000); +assert.equal(close.dashboard.status, "attention-needed"); + +const subscription = close.contracts.find((contract) => contract.id === "sub-lab-annual"); +assert.equal(subscription.revenue.method, "ratable-service-period"); +assert.equal(subscription.revenue.recognizedThisPeriod, 986.3); +assert.equal(subscription.closeStatus, "closed"); + +const compute = close.contracts.find((contract) => contract.id === "compute-foundation-burst"); +assert.equal(compute.revenue.method, "billable-usage-events"); +assert.equal(compute.revenue.recognizedThisPeriod, 1950); +assert.equal(compute.revenue.deferredAfterClose, 1250); +assert.equal(compute.payment.status, "open-receivable"); +assert.equal(compute.dunning.status, "not-needed"); +assert.equal(compute.closeStatus, "closed-with-receivable"); +assert.equal(compute.revenue.evidence.length, 2); + +const license = close.contracts.find((contract) => contract.id === "license-policy-api"); +assert.equal(license.revenue.method, "accepted-license-deliverables"); +assert.equal(license.revenue.recognizedThisPeriod, 5000); +assert.equal(license.revenue.deferredAfterClose, 4000); +assert.equal(license.dunning.status, "queued"); +assert.equal(license.dunning.action, "finance-review"); +assert.equal(license.holds.length, 2); +assert.ok(close.auditTrail.some((event) => event.type === "dunning-queued")); +assert.ok(close.auditTrail.some((event) => event.type === "close-hold" && event.holdType === "credit-review")); +assert.equal(close.digest, buildRevenueClose(closeInput).digest); + +const incomplete = validateCloseInput({ closeId: "draft" }); +assert.equal(incomplete.status, "incomplete"); +assert.ok(incomplete.missing.includes("periodStart")); + +const noDunningInput = JSON.parse(JSON.stringify(closeInput)); +noDunningInput.contracts[2].paidAt = "2026-04-20T09:00:00Z"; +noDunningInput.contracts[2].credits = []; +const noDunningClose = buildRevenueClose(noDunningInput); +assert.equal(noDunningClose.dunningQueue.length, 0); +assert.equal(noDunningClose.creditAndRefundHolds.length, 0); + +console.log("revenue-recognition-close tests passed");