diff --git a/revenue-usage-reconciliation/README.md b/revenue-usage-reconciliation/README.md new file mode 100644 index 0000000..3a35d68 --- /dev/null +++ b/revenue-usage-reconciliation/README.md @@ -0,0 +1,46 @@ +# Revenue Usage Reconciliation + +This module adds a focused revenue-ops validation layer for SCIBASE revenue infrastructure. +It reconciles plan entitlements, compute usage, top-ups, invoice totals, and anonymized analytics licensing exports so reviewers can catch undercharges, overcharges, refund risk, and unsafe licensing exports before closing a billing cycle. + +It is intentionally dependency-free and can be run with stock Node.js. + +## What It Covers + +- Tiered plan quotas and included subscription charges. +- AI compute usage meters by capability. +- Top-up credit reconciliation before overage calculation. +- Invoice comparison against expected charges. +- Undercharge and overcharge anomaly detection. +- Anonymized licensing export safety checks. +- Revenue health reports with high-risk account lists and audit hashes. +- Entitlement regression matrix for month-over-month review. + +## Demo + +```bash +npm run demo +``` + +The demo prints a report with one clean lab account, one undercharged institutional account, and one unsafe licensing export. +Text-only demo evidence is included in `docs/demo-transcript.md` for reviewers who prefer not to inspect the GIF. + +## Verification + +```bash +npm run check +npm test +npm run demo +``` + +## Files + +- `src/reconciliation.js` - core reconciliation, anomaly, health report, and regression logic. +- `test/reconciliation.test.js` - focused tests for clean billing, undercharges, licensing risk, aggregation, and regressions. +- `scripts/demo.js` - CLI demo with sample subscription, usage, invoice, and licensing data. +- `docs/issue-20-requirement-map.md` - mapping from issue requirements to implementation evidence. +- `docs/demo-transcript.md` - text-only reviewer evidence for the demo scenario. + +## AI-Assisted Disclosure + +This contribution was produced with AI assistance and manually verified before submission. diff --git a/revenue-usage-reconciliation/docs/demo-transcript.md b/revenue-usage-reconciliation/docs/demo-transcript.md new file mode 100644 index 0000000..b61504c --- /dev/null +++ b/revenue-usage-reconciliation/docs/demo-transcript.md @@ -0,0 +1,24 @@ +# Revenue Usage Reconciliation Demo Transcript + +This transcript gives reviewers a text-only demo artifact for issue #20 in addition to the GIF. + +## Command + +```bash +npm run demo +``` + +## Expected Highlights + +- Report status is `needs-revenue-ops-review`. +- Total expected revenue is `313000` cents. +- Total actual invoice revenue is `309800` cents. +- Delta is `-3200` cents. +- Two anomalies are reported. +- `institute-undercharged` is flagged for a medium-severity undercharge. +- `agency-export-risk` is flagged for a high-severity unsafe licensing export. +- The regression matrix keeps `lab-clean` clean and marks new risky accounts as review-required. + +## Reviewer Value + +The demo proves this module can catch revenue leakage, refund-risk style billing mismatches, and unsafe analytics licensing exports before a billing cycle is closed. diff --git a/revenue-usage-reconciliation/docs/demo.gif b/revenue-usage-reconciliation/docs/demo.gif new file mode 100644 index 0000000..a18c06f Binary files /dev/null and b/revenue-usage-reconciliation/docs/demo.gif differ diff --git a/revenue-usage-reconciliation/docs/issue-20-requirement-map.md b/revenue-usage-reconciliation/docs/issue-20-requirement-map.md new file mode 100644 index 0000000..e821da2 --- /dev/null +++ b/revenue-usage-reconciliation/docs/issue-20-requirement-map.md @@ -0,0 +1,20 @@ +# Issue #20 Requirement Map + +This module is a focused revenue operations slice for the SCIBASE revenue infrastructure bounty. It is not another payment provider mock; it verifies whether usage, entitlements, invoices, top-ups, and analytics licensing exports reconcile cleanly before the month is closed. + +| Issue #20 requirement | Implementation evidence | +| --- | --- | +| Tiered subscription billing | `normalizeAccount` models plan name, included quota, included price, and overage rate. | +| Volume/top-up support | `expectedChargeCents` includes top-up credits before calculating overage units. | +| AI compute usage billing | `summarizeUsage` aggregates billable and non-billable usage by capability. | +| Transparent quotas and usage meters | `reconcileAccount` reports included units, billable units, overage units, expected charge, actual charge, and delta. | +| Institutional invoicing | Invoice totals are compared against expected entitlement and usage charges. | +| Licensing APIs and analytics | `inspectLicensingExports` checks that analytics exports are anonymized and do not expose private fields. | +| Revenue health reporting | `buildRevenueHealthReport` summarizes total expected/actual revenue, anomaly counts, high-risk accounts, and an audit hash. | +| Regression evidence | `buildEntitlementRegressionMatrix` flags accounts that moved from clean to review-required between billing runs. | + +## Reviewer Notes + +- Dependency-free Node.js implementation for easy review. +- Designed as a revenue-ops validation layer that can sit beside existing billing or entitlement engines. +- Demo output includes one clean lab account, one undercharged institution, and one unsafe analytics export. diff --git a/revenue-usage-reconciliation/package.json b/revenue-usage-reconciliation/package.json new file mode 100644 index 0000000..25bf9b9 --- /dev/null +++ b/revenue-usage-reconciliation/package.json @@ -0,0 +1,13 @@ +{ + "name": "revenue-usage-reconciliation", + "version": "1.0.0", + "description": "Usage anomaly reconciliation and entitlement regression reporting for SCIBASE revenue infrastructure.", + "main": "src/reconciliation.js", + "type": "commonjs", + "scripts": { + "check": "node --check src/reconciliation.js && node --check scripts/demo.js && node --check test/reconciliation.test.js", + "demo": "node scripts/demo.js", + "test": "node test/reconciliation.test.js" + }, + "license": "Apache-2.0" +} diff --git a/revenue-usage-reconciliation/scripts/demo.js b/revenue-usage-reconciliation/scripts/demo.js new file mode 100644 index 0000000..0b243f0 --- /dev/null +++ b/revenue-usage-reconciliation/scripts/demo.js @@ -0,0 +1,69 @@ +"use strict"; + +const { + buildRevenueHealthReport, + buildEntitlementRegressionMatrix +} = require("../src/reconciliation"); + +const previousAccounts = [ + { + id: "lab-clean", + plan: { name: "Lab Pro", monthlyQuotaUnits: 1000, includedCents: 9900, overageCentsPerUnit: 12 }, + topUps: [{ id: "top-1", units: 200 }], + usageEvents: [{ capability: "ai-review", units: 900 }], + invoices: [{ id: "inv-1", amountCents: 9900 }], + licensingExports: [{ id: "lic-1", dataset: "topic-trends", anonymized: true, fields: ["topic", "score"] }] + } +]; + +const currentAccounts = [ + { + id: "lab-clean", + plan: { name: "Lab Pro", monthlyQuotaUnits: 1000, includedCents: 9900, overageCentsPerUnit: 12 }, + topUps: [{ id: "top-1", units: 200 }], + usageEvents: [{ capability: "ai-review", units: 900 }], + invoices: [{ id: "inv-1", amountCents: 9900 }], + licensingExports: [{ id: "lic-1", dataset: "topic-trends", anonymized: true, fields: ["topic", "score"] }] + }, + { + id: "institute-undercharged", + plan: { name: "Institution", monthlyQuotaUnits: 5000, includedCents: 49900, overageCentsPerUnit: 8 }, + topUps: [], + usageEvents: [ + { capability: "reproducibility-run", units: 5400 }, + { capability: "literature-scan", units: 200, billable: false } + ], + invoices: [{ id: "inv-2", amountCents: 49900 }], + licensingExports: [{ id: "lic-2", dataset: "reuse-map", anonymized: true, fields: ["topic", "method", "score"] }] + }, + { + id: "agency-export-risk", + plan: { name: "Analytics License", monthlyQuotaUnits: 0, includedCents: 250000, overageCentsPerUnit: 0 }, + topUps: [], + usageEvents: [], + invoices: [{ id: "inv-3", amountCents: 250000 }], + licensingExports: [{ id: "lic-3", dataset: "grant-impact", anonymized: false, fields: ["topic", "orcid", "raw_email"] }] + } +]; + +const previous = buildRevenueHealthReport(previousAccounts); +const current = buildRevenueHealthReport(currentAccounts); +const regression = buildEntitlementRegressionMatrix(previous, current); + +console.log(JSON.stringify({ + report: { + status: current.status, + totals: current.totals, + highRiskAccounts: current.highRiskAccounts, + auditHash: current.auditHash + }, + regression, + anomalies: current.reconciliations.flatMap((account) => + account.anomalies.map((anomaly) => ({ + accountId: account.accountId, + type: anomaly.type, + severity: anomaly.severity, + amountCents: anomaly.amountCents || 0 + })) + ) +}, null, 2)); diff --git a/revenue-usage-reconciliation/src/reconciliation.js b/revenue-usage-reconciliation/src/reconciliation.js new file mode 100644 index 0000000..0064f5e --- /dev/null +++ b/revenue-usage-reconciliation/src/reconciliation.js @@ -0,0 +1,185 @@ +"use strict"; + +const crypto = require("crypto"); + +function money(cents) { + return Math.round(cents); +} + +function hashPayload(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function normalizeAccount(account) { + if (!account || !account.id || !account.plan) { + throw new Error("account requires id and plan"); + } + + return { + id: account.id, + plan: { + name: account.plan.name, + monthlyQuotaUnits: account.plan.monthlyQuotaUnits || 0, + includedCents: money(account.plan.includedCents || 0), + overageCentsPerUnit: money(account.plan.overageCentsPerUnit || 0) + }, + topUps: Array.isArray(account.topUps) ? account.topUps : [], + invoices: Array.isArray(account.invoices) ? account.invoices : [], + usageEvents: Array.isArray(account.usageEvents) ? account.usageEvents : [], + licensingExports: Array.isArray(account.licensingExports) ? account.licensingExports : [] + }; +} + +function summarizeUsage(usageEvents) { + return usageEvents.reduce((summary, event) => { + const units = Number(event.units || 0); + summary.totalUnits += units; + summary.byCapability[event.capability] = (summary.byCapability[event.capability] || 0) + units; + if (event.billable === false) { + summary.nonBillableUnits += units; + } + return summary; + }, { + totalUnits: 0, + nonBillableUnits: 0, + byCapability: {} + }); +} + +function summarizeCredits(topUps) { + return topUps.reduce((total, topUp) => total + Number(topUp.units || 0), 0); +} + +function expectedChargeCents(account, usageSummary) { + const includedUnits = account.plan.monthlyQuotaUnits + summarizeCredits(account.topUps); + const billableUnits = Math.max(0, usageSummary.totalUnits - usageSummary.nonBillableUnits); + const overageUnits = Math.max(0, billableUnits - includedUnits); + return { + includedUnits, + billableUnits, + overageUnits, + expectedCents: money(account.plan.includedCents + (overageUnits * account.plan.overageCentsPerUnit)) + }; +} + +function actualInvoiceCents(invoices) { + return money(invoices.reduce((total, invoice) => total + Number(invoice.amountCents || 0), 0)); +} + +function inspectLicensingExports(exports) { + return exports.map((item) => { + const fields = Array.isArray(item.fields) ? item.fields : []; + const privateFields = fields.filter((field) => /email|name|orcid|private|raw/i.test(field)); + return { + id: item.id, + dataset: item.dataset, + anonymized: item.anonymized === true, + privateFields, + safeForLicensing: item.anonymized === true && privateFields.length === 0 + }; + }); +} + +function reconcileAccount(accountInput) { + const account = normalizeAccount(accountInput); + const usageSummary = summarizeUsage(account.usageEvents); + const expected = expectedChargeCents(account, usageSummary); + const actualCents = actualInvoiceCents(account.invoices); + const deltaCents = money(actualCents - expected.expectedCents); + const licensing = inspectLicensingExports(account.licensingExports); + const anomalies = []; + + if (deltaCents < 0) { + anomalies.push({ + type: "undercharge", + severity: Math.abs(deltaCents) > 5000 ? "high" : "medium", + amountCents: Math.abs(deltaCents), + message: "Invoice total is lower than expected entitlement and overage charge." + }); + } + + if (deltaCents > 0) { + anomalies.push({ + type: "overcharge", + severity: deltaCents > 5000 ? "high" : "medium", + amountCents: deltaCents, + message: "Invoice total is higher than expected entitlement and overage charge." + }); + } + + for (const exportCheck of licensing) { + if (!exportCheck.safeForLicensing) { + anomalies.push({ + type: "licensing-export-risk", + severity: "high", + exportId: exportCheck.id, + message: "Licensing export is not safely anonymized for institutional analytics." + }); + } + } + + return { + accountId: account.id, + planName: account.plan.name, + usageSummary, + expected, + actualCents, + deltaCents, + licensing, + anomalies + }; +} + +function buildRevenueHealthReport(accounts) { + const reconciliations = accounts.map(reconcileAccount); + const totals = reconciliations.reduce((summary, item) => { + summary.expectedCents += item.expected.expectedCents; + summary.actualCents += item.actualCents; + summary.deltaCents += item.deltaCents; + summary.anomalyCount += item.anomalies.length; + return summary; + }, { + expectedCents: 0, + actualCents: 0, + deltaCents: 0, + anomalyCount: 0 + }); + + const highRiskAccounts = reconciliations + .filter((item) => item.anomalies.some((anomaly) => anomaly.severity === "high")) + .map((item) => item.accountId); + + return { + status: totals.anomalyCount === 0 ? "ready-for-close" : "needs-revenue-ops-review", + totals, + highRiskAccounts, + reconciliations, + auditHash: hashPayload(reconciliations) + }; +} + +function buildEntitlementRegressionMatrix(previousReport, currentReport) { + const previousByAccount = new Map(previousReport.reconciliations.map((item) => [item.accountId, item])); + return currentReport.reconciliations.map((current) => { + const previous = previousByAccount.get(current.accountId); + const previousStatus = previous + ? (previous.anomalies.length === 0 ? "clean" : "review") + : "new-account"; + const currentStatus = current.anomalies.length === 0 ? "clean" : "review"; + return { + accountId: current.accountId, + previousStatus, + currentStatus, + regressed: previousStatus === "clean" && currentStatus === "review", + deltaCents: current.deltaCents - (previous ? previous.deltaCents : 0) + }; + }); +} + +module.exports = { + normalizeAccount, + summarizeUsage, + reconcileAccount, + buildRevenueHealthReport, + buildEntitlementRegressionMatrix +}; diff --git a/revenue-usage-reconciliation/test/reconciliation.test.js b/revenue-usage-reconciliation/test/reconciliation.test.js new file mode 100644 index 0000000..71f4788 --- /dev/null +++ b/revenue-usage-reconciliation/test/reconciliation.test.js @@ -0,0 +1,101 @@ +"use strict"; + +const assert = require("assert"); +const { + reconcileAccount, + buildRevenueHealthReport, + buildEntitlementRegressionMatrix +} = require("../src/reconciliation"); + +function cleanAccount() { + return { + id: "lab-clean", + plan: { name: "Lab Pro", monthlyQuotaUnits: 100, includedCents: 10000, overageCentsPerUnit: 25 }, + topUps: [{ units: 50 }], + usageEvents: [ + { capability: "ai-review", units: 125 }, + { capability: "literature-scan", units: 25 } + ], + invoices: [{ amountCents: 10000 }], + licensingExports: [{ id: "lic-1", dataset: "topic-trends", anonymized: true, fields: ["topic", "score"] }] + }; +} + +function testCleanAccountHasNoAnomalies() { + const result = reconcileAccount(cleanAccount()); + assert.strictEqual(result.expected.expectedCents, 10000); + assert.strictEqual(result.deltaCents, 0); + assert.deepStrictEqual(result.anomalies, []); +} + +function testUnderchargeDetectedForOverQuotaUsage() { + const account = cleanAccount(); + account.usageEvents.push({ capability: "reproducibility-run", units: 20 }); + const result = reconcileAccount(account); + assert.strictEqual(result.expected.overageUnits, 20); + assert.strictEqual(result.anomalies[0].type, "undercharge"); + assert.strictEqual(result.anomalies[0].amountCents, 500); +} + +function testOverchargeDetectedForRefundRisk() { + const account = cleanAccount(); + account.invoices = [{ amountCents: 11250 }]; + const result = reconcileAccount(account); + assert.strictEqual(result.deltaCents, 1250); + assert.strictEqual(result.anomalies[0].type, "overcharge"); + assert.strictEqual(result.anomalies[0].amountCents, 1250); +} + +function testLicensingExportRiskDetected() { + const account = cleanAccount(); + account.licensingExports = [{ id: "lic-risk", dataset: "grant-map", anonymized: false, fields: ["orcid", "raw_email"] }]; + const result = reconcileAccount(account); + assert.strictEqual(result.anomalies[0].type, "licensing-export-risk"); + assert.strictEqual(result.licensing[0].safeForLicensing, false); +} + +function testRevenueHealthReportAggregatesRisk() { + const risky = cleanAccount(); + risky.id = "risky"; + risky.invoices = [{ amountCents: 20000 }]; + const report = buildRevenueHealthReport([cleanAccount(), risky]); + assert.strictEqual(report.status, "needs-revenue-ops-review"); + assert.strictEqual(report.totals.anomalyCount, 1); + assert.deepStrictEqual(report.highRiskAccounts, ["risky"]); + assert.ok(report.auditHash.length >= 32); +} + +function testEntitlementRegressionMatrixFlagsNewReviewState() { + const previous = buildRevenueHealthReport([cleanAccount()]); + const currentAccount = cleanAccount(); + currentAccount.invoices = [{ amountCents: 8000 }]; + const current = buildRevenueHealthReport([currentAccount]); + const matrix = buildEntitlementRegressionMatrix(previous, current); + assert.strictEqual(matrix[0].previousStatus, "clean"); + assert.strictEqual(matrix[0].currentStatus, "review"); + assert.strictEqual(matrix[0].regressed, true); +} + +function testEntitlementRegressionMatrixMarksNewAccounts() { + const previous = buildRevenueHealthReport([]); + const current = buildRevenueHealthReport([cleanAccount()]); + const matrix = buildEntitlementRegressionMatrix(previous, current); + assert.strictEqual(matrix[0].previousStatus, "new-account"); + assert.strictEqual(matrix[0].currentStatus, "clean"); +} + +const tests = [ + testCleanAccountHasNoAnomalies, + testUnderchargeDetectedForOverQuotaUsage, + testOverchargeDetectedForRefundRisk, + testLicensingExportRiskDetected, + testRevenueHealthReportAggregatesRisk, + testEntitlementRegressionMatrixFlagsNewReviewState, + testEntitlementRegressionMatrixMarksNewAccounts +]; + +for (const test of tests) { + test(); +} + +console.log(`${tests.length} revenue reconciliation tests passed`);