diff --git a/revenue-dunning-entitlement-hold-guard/demo.js b/revenue-dunning-entitlement-hold-guard/demo.js new file mode 100644 index 00000000..24e7a56d --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/demo.js @@ -0,0 +1,55 @@ +const fs = require("fs"); +const path = require("path"); +const { billingBatch } = require("./sample-data"); +const { evaluateBatch } = require("./index"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const report = evaluateBatch(billingBatch); +fs.writeFileSync(path.join(reportsDir, "dunning-entitlement-report.json"), `${JSON.stringify(report, null, 2)}\n`); + +const markdown = [ + "# Revenue Dunning and Entitlement Hold Report", + "", + `Batch: ${report.batchId}`, + `Generated: ${report.generatedAt}`, + "", + "## Summary", + "", + `- Accounts evaluated: ${report.summary.total}`, + `- Kept active: ${report.summary["keep-active"] || 0}`, + `- Finance exceptions: ${report.summary["finance-exception"] || 0}`, + `- Entitlement holds: ${report.summary["hold-entitlements"] || 0}`, + `- Findings: ${report.summary.findingCount}`, + "", + "## Decisions", + "", + ...report.accounts.flatMap((account) => [ + `### ${account.id}`, + "", + `- Decision: ${account.decision}`, + `- Findings: ${account.findings.length ? account.findings.join("; ") : "none"}`, + `- Actions: ${account.actions.join("; ")}`, + "" + ]) +].join("\n").trim(); + +fs.writeFileSync(path.join(reportsDir, "dunning-entitlement-report.md"), `${markdown}\n`); + +const svg = ` + + Dunning Entitlement Guard + Failed-payment holds for compute and analytics revenue + ${report.accounts.map((account, index) => { + const y = 150 + index * 82; + const color = account.decision === "keep-active" ? "#047857" : account.decision === "finance-exception" ? "#2563eb" : "#b45309"; + return ` + ${account.id} + ${account.decision} + ${account.findings.length} finding(s)`; + }).join("\n ")} + +`; +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg); +console.log(JSON.stringify(report.summary, null, 2)); diff --git a/revenue-dunning-entitlement-hold-guard/index.js b/revenue-dunning-entitlement-hold-guard/index.js new file mode 100644 index 00000000..0a27802f --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/index.js @@ -0,0 +1,47 @@ +function evaluateAccount(account) { + const findings = []; + + if (account.invoiceStatus === "paid") { + return { + id: account.id, + decision: "keep-active", + findings, + actions: ["keep entitlements active", "allow queued exports"] + }; + } + + if (account.failedAttempts >= 3) findings.push("retry limit exceeded"); + if (account.daysPastDue >= 7 && !account.dunningNotices.includes("day-7")) findings.push("missing day-7 dunning notice"); + if (account.daysPastDue >= 14 && !account.dunningNotices.includes("day-14")) findings.push("missing day-14 dunning notice"); + if (account.entitlement === "active" && account.daysPastDue >= 14) findings.push("active entitlement beyond grace window"); + if (account.computeCreditsRemaining > 0 && account.failedAttempts >= 3) findings.push("compute credits should be held after repeated payment failure"); + if (account.analyticsExportQueued && account.invoiceStatus === "failed") findings.push("analytics license export queued for unpaid account"); + if (account.institutionalInvoice && account.failedAttempts < 3) findings.push("institutional invoice should route to finance exception workflow"); + + const decision = account.institutionalInvoice && account.failedAttempts < 3 + ? "finance-exception" + : findings.length > 0 + ? "hold-entitlements" + : "continue-grace"; + + const actions = { + "finance-exception": ["route to institutional billing desk", "pause analytics export until invoice evidence is attached"], + "hold-entitlements": ["hold risky entitlements", "pause compute credits or exports", "send remediation notice"], + "continue-grace": ["continue grace period", "schedule next dunning notice"] + }[decision]; + + return { id: account.id, decision, findings, actions }; +} + +function evaluateBatch(batch) { + const accounts = batch.accounts.map(evaluateAccount); + const summary = accounts.reduce((acc, account) => { + acc.total += 1; + acc[account.decision] = (acc[account.decision] || 0) + 1; + acc.findingCount += account.findings.length; + return acc; + }, { total: 0, findingCount: 0 }); + return { batchId: batch.id, generatedAt: batch.generatedAt, summary, accounts }; +} + +module.exports = { evaluateAccount, evaluateBatch }; diff --git a/revenue-dunning-entitlement-hold-guard/readme.md b/revenue-dunning-entitlement-hold-guard/readme.md new file mode 100644 index 00000000..8473a80c --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/readme.md @@ -0,0 +1,33 @@ +# Revenue Dunning Entitlement Hold Guard + +This module adds a focused failed-payment control for SCIBASE Revenue Infrastructure. It decides when renewal invoices and AI-compute top-ups should continue grace, route to finance exception handling, or hold risky entitlements after payment failures. + +It is intentionally separate from broad billing ledgers, usage metering, receipt privacy, grant cost-share controls, sanctions/export checks, quote approval, seat rosters, payment authorization, customer consolidation, and revenue recognition. + +## What It Checks + +- Failed payment retry windows +- Dunning notice cadence +- Entitlement grace periods +- Institutional invoice exceptions +- AI compute-credit hold safety +- Analytics-license export holds + +## Demo + +Run: + +```bash +node revenue-dunning-entitlement-hold-guard/test.js +node revenue-dunning-entitlement-hold-guard/demo.js +node revenue-dunning-entitlement-hold-guard/render-video.js +``` + +Generated artifacts: + +- `reports/dunning-entitlement-report.json` +- `reports/dunning-entitlement-report.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +All data is synthetic. The module does not call payment providers, banks, wallets, billing systems, ERP systems, private accounts, credentials, or SCIBASE production services. diff --git a/revenue-dunning-entitlement-hold-guard/render-video.js b/revenue-dunning-entitlement-hold-guard/render-video.js new file mode 100644 index 00000000..59fe2648 --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/render-video.js @@ -0,0 +1,38 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); +const width = 960; +const height = 540; +const ppm = path.join(reportsDir, "demo-frame.ppm"); +const mp4 = path.join(reportsDir, "demo.mp4"); + +function pixel(x, y) { + if (y < 112) return [23, 32, 51]; + if (x > 46 && x < 914 && y > 126 && y < 188) return [255, 255, 255]; + if (x > 46 && x < 914 && y > 210 && y < 272) return [255, 255, 255]; + if (x > 46 && x < 914 && y > 294 && y < 356) return [255, 255, 255]; + if (x > 46 && x < 914 && y > 378 && y < 440) return [255, 255, 255]; + if (x > 64 && x < 238 && y > 140 && y < 174) return [4, 120, 87]; + if (x > 64 && x < 238 && y > 224 && y < 258) return [37, 99, 235]; + if (x > 64 && x < 238 && y > 308 && y < 342) return [180, 83, 9]; + if (x > 64 && x < 238 && y > 392 && y < 426) return [180, 83, 9]; + return [248, 250, 252]; +} +const header = `P6\n${width} ${height}\n255\n`; +const body = Buffer.alloc(width * height * 3); +for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const offset = (y * width + x) * 3; + const [r, g, b] = pixel(x, y); + body[offset] = r; + body[offset + 1] = g; + body[offset + 2] = b; + } +} +fs.writeFileSync(ppm, Buffer.concat([Buffer.from(header), body])); +const result = spawnSync("ffmpeg", ["-y", "-loop", "1", "-framerate", "24", "-i", ppm, "-t", "5", "-vf", "format=yuv420p", "-movflags", "+faststart", mp4], { stdio: "inherit" }); +if (result.status !== 0) throw new Error("ffmpeg failed to render demo video"); +console.log(mp4); diff --git a/revenue-dunning-entitlement-hold-guard/reports/demo.mp4 b/revenue-dunning-entitlement-hold-guard/reports/demo.mp4 new file mode 100644 index 00000000..638c1cc1 Binary files /dev/null and b/revenue-dunning-entitlement-hold-guard/reports/demo.mp4 differ diff --git a/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.json b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.json new file mode 100644 index 00000000..604391ae --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.json @@ -0,0 +1,61 @@ +{ + "batchId": "revenue-dunning-2026-05-29", + "generatedAt": "2026-05-29T15:04:00.000Z", + "summary": { + "total": 4, + "findingCount": 7, + "keep-active": 1, + "finance-exception": 1, + "hold-entitlements": 2 + }, + "accounts": [ + { + "id": "acct-lab-1", + "decision": "keep-active", + "findings": [], + "actions": [ + "keep entitlements active", + "allow queued exports" + ] + }, + { + "id": "acct-inst-2", + "decision": "finance-exception", + "findings": [ + "analytics license export queued for unpaid account", + "institutional invoice should route to finance exception workflow" + ], + "actions": [ + "route to institutional billing desk", + "pause analytics export until invoice evidence is attached" + ] + }, + { + "id": "acct-ai-3", + "decision": "hold-entitlements", + "findings": [ + "retry limit exceeded", + "active entitlement beyond grace window", + "compute credits should be held after repeated payment failure" + ], + "actions": [ + "hold risky entitlements", + "pause compute credits or exports", + "send remediation notice" + ] + }, + { + "id": "acct-api-4", + "decision": "hold-entitlements", + "findings": [ + "missing day-7 dunning notice", + "analytics license export queued for unpaid account" + ], + "actions": [ + "hold risky entitlements", + "pause compute credits or exports", + "send remediation notice" + ] + } + ] +} diff --git a/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.md b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.md new file mode 100644 index 00000000..e11081e2 --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.md @@ -0,0 +1,38 @@ +# Revenue Dunning and Entitlement Hold Report + +Batch: revenue-dunning-2026-05-29 +Generated: 2026-05-29T15:04:00.000Z + +## Summary + +- Accounts evaluated: 4 +- Kept active: 1 +- Finance exceptions: 1 +- Entitlement holds: 2 +- Findings: 7 + +## Decisions + +### acct-lab-1 + +- Decision: keep-active +- Findings: none +- Actions: keep entitlements active; allow queued exports + +### acct-inst-2 + +- Decision: finance-exception +- Findings: analytics license export queued for unpaid account; institutional invoice should route to finance exception workflow +- Actions: route to institutional billing desk; pause analytics export until invoice evidence is attached + +### acct-ai-3 + +- Decision: hold-entitlements +- Findings: retry limit exceeded; active entitlement beyond grace window; compute credits should be held after repeated payment failure +- Actions: hold risky entitlements; pause compute credits or exports; send remediation notice + +### acct-api-4 + +- Decision: hold-entitlements +- Findings: missing day-7 dunning notice; analytics license export queued for unpaid account +- Actions: hold risky entitlements; pause compute credits or exports; send remediation notice diff --git a/revenue-dunning-entitlement-hold-guard/reports/summary.svg b/revenue-dunning-entitlement-hold-guard/reports/summary.svg new file mode 100644 index 00000000..400694c6 --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/reports/summary.svg @@ -0,0 +1,21 @@ + + + Dunning Entitlement Guard + Failed-payment holds for compute and analytics revenue + + acct-lab-1 + keep-active + 0 finding(s) + + acct-inst-2 + finance-exception + 2 finding(s) + + acct-ai-3 + hold-entitlements + 3 finding(s) + + acct-api-4 + hold-entitlements + 2 finding(s) + diff --git a/revenue-dunning-entitlement-hold-guard/sample-data.js b/revenue-dunning-entitlement-hold-guard/sample-data.js new file mode 100644 index 00000000..1160e9a1 --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/sample-data.js @@ -0,0 +1,56 @@ +const billingBatch = { + id: "revenue-dunning-2026-05-29", + generatedAt: "2026-05-29T15:04:00.000Z", + accounts: [ + { + id: "acct-lab-1", + plan: "lab-pro", + invoiceStatus: "paid", + failedAttempts: 0, + daysPastDue: 0, + dunningNotices: [], + entitlement: "active", + computeCreditsRemaining: 120, + institutionalInvoice: false, + analyticsExportQueued: false + }, + { + id: "acct-inst-2", + plan: "institution", + invoiceStatus: "failed", + failedAttempts: 1, + daysPastDue: 5, + dunningNotices: ["day-1"], + entitlement: "active", + computeCreditsRemaining: 700, + institutionalInvoice: true, + analyticsExportQueued: true + }, + { + id: "acct-ai-3", + plan: "compute-pack", + invoiceStatus: "failed", + failedAttempts: 4, + daysPastDue: 18, + dunningNotices: ["day-1", "day-7", "day-14"], + entitlement: "active", + computeCreditsRemaining: 30, + institutionalInvoice: false, + analyticsExportQueued: false + }, + { + id: "acct-api-4", + plan: "analytics-api", + invoiceStatus: "failed", + failedAttempts: 2, + daysPastDue: 9, + dunningNotices: ["day-1"], + entitlement: "grace", + computeCreditsRemaining: 0, + institutionalInvoice: false, + analyticsExportQueued: true + } + ] +}; + +module.exports = { billingBatch }; diff --git a/revenue-dunning-entitlement-hold-guard/test.js b/revenue-dunning-entitlement-hold-guard/test.js new file mode 100644 index 00000000..eea73510 --- /dev/null +++ b/revenue-dunning-entitlement-hold-guard/test.js @@ -0,0 +1,24 @@ +const assert = require("assert"); +const { billingBatch } = require("./sample-data"); +const { evaluateBatch } = require("./index"); + +const report = evaluateBatch(billingBatch); + +assert.strictEqual(report.summary.total, 4); +assert.strictEqual(report.summary["keep-active"], 1); +assert.strictEqual(report.summary["finance-exception"], 1); +assert.strictEqual(report.summary["hold-entitlements"], 2); + +const institutional = report.accounts.find((account) => account.id === "acct-inst-2"); +assert.strictEqual(institutional.decision, "finance-exception"); +assert(institutional.actions.some((action) => action.includes("institutional billing"))); + +const compute = report.accounts.find((account) => account.id === "acct-ai-3"); +assert(compute.findings.some((finding) => finding.includes("retry limit"))); +assert(compute.findings.some((finding) => finding.includes("compute credits"))); + +const analytics = report.accounts.find((account) => account.id === "acct-api-4"); +assert(analytics.findings.some((finding) => finding.includes("missing day-7"))); +assert(analytics.findings.some((finding) => finding.includes("analytics license export"))); + +console.log("revenue-dunning-entitlement-hold-guard tests passed");