Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions revenue-dunning-entitlement-hold-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#f8fafc"/>
<text x="48" y="64" font-family="Arial" font-size="28" font-weight="700" fill="#172033">Dunning Entitlement Guard</text>
<text x="48" y="102" font-family="Arial" font-size="16" fill="#4f5d75">Failed-payment holds for compute and analytics revenue</text>
${report.accounts.map((account, index) => {
const y = 150 + index * 82;
const color = account.decision === "keep-active" ? "#047857" : account.decision === "finance-exception" ? "#2563eb" : "#b45309";
return `<rect x="48" y="${y - 34}" width="864" height="58" rx="6" fill="#ffffff" stroke="#d7dce6"/>
<text x="72" y="${y - 8}" font-family="Arial" font-size="18" font-weight="700" fill="#172033">${account.id}</text>
<text x="72" y="${y + 16}" font-family="Arial" font-size="14" fill="${color}">${account.decision}</text>
<text x="310" y="${y + 16}" font-family="Arial" font-size="14" fill="#4f5d75">${account.findings.length} finding(s)</text>`;
}).join("\n ")}
</svg>
`;
fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg);
console.log(JSON.stringify(report.summary, null, 2));
47 changes: 47 additions & 0 deletions revenue-dunning-entitlement-hold-guard/index.js
Original file line number Diff line number Diff line change
@@ -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 };
33 changes: 33 additions & 0 deletions revenue-dunning-entitlement-hold-guard/readme.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions revenue-dunning-entitlement-hold-guard/render-video.js
Original file line number Diff line number Diff line change
@@ -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);
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions revenue-dunning-entitlement-hold-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 56 additions & 0 deletions revenue-dunning-entitlement-hold-guard/sample-data.js
Original file line number Diff line number Diff line change
@@ -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 };
24 changes: 24 additions & 0 deletions revenue-dunning-entitlement-hold-guard/test.js
Original file line number Diff line number Diff line change
@@ -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");