From 89f239f4e8329834cc1632e75042bc48df041765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=93=AD=E9=94=8B?= Date: Fri, 15 May 2026 13:30:24 +0800 Subject: [PATCH] Add revenue recognition close controls --- revenue-recognition-close/README.md | 42 ++ .../data/sample-close.json | 115 +++++ revenue-recognition-close/docs/demo.mp4 | Bin 0 -> 38104 bytes revenue-recognition-close/docs/demo.svg | 29 ++ .../docs/requirement-map.md | 30 ++ revenue-recognition-close/package.json | 18 + revenue-recognition-close/scripts/demo.js | 17 + .../src/revenue-close.js | 425 ++++++++++++++++++ .../test/revenue-close.test.js | 54 +++ 9 files changed, 730 insertions(+) create mode 100644 revenue-recognition-close/README.md create mode 100644 revenue-recognition-close/data/sample-close.json create mode 100644 revenue-recognition-close/docs/demo.mp4 create mode 100644 revenue-recognition-close/docs/demo.svg create mode 100644 revenue-recognition-close/docs/requirement-map.md create mode 100644 revenue-recognition-close/package.json create mode 100644 revenue-recognition-close/scripts/demo.js create mode 100644 revenue-recognition-close/src/revenue-close.js create mode 100644 revenue-recognition-close/test/revenue-close.test.js 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 0000000000000000000000000000000000000000..ba0fa0fed17b3f027c0010c3e8257ba6a535f851 GIT binary patch literal 38104 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 O-W;gwnSnnhrvC>bx2LrL literal 0 HcmV?d00001 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");