diff --git a/prepaid-compute-credit-breakage-guard/README.md b/prepaid-compute-credit-breakage-guard/README.md new file mode 100644 index 00000000..e3d3d699 --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/README.md @@ -0,0 +1,24 @@ +# Prepaid Compute Credit Breakage Guard + +This module is a focused Revenue Infrastructure slice for issue #20. It evaluates prepaid AI compute and top-up credit lots before finance recognizes expired unused balances as breakage revenue. + +The guard keeps protected balances out of revenue, routes open refund obligations to liability review, requires notice and terms evidence before breakage, and emits deterministic audit artifacts for revenue close review. + +## What It Checks + +- Expired credit lots with explicit breakage terms, customer notice evidence, and closed refund windows. +- Expired lots that must stay on finance hold because evidence is missing or a dispute is active. +- Credits approaching expiration that need a customer notice before close. +- Grant-funded or no-expiration credits that must carry forward. +- Refund-window balances that remain liabilities. +- Usable active balances that should not be recognized. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +Demo artifacts are written under `reports/`, including JSON, Markdown, SVG, and the generated MP4 demo attached in the PR. diff --git a/prepaid-compute-credit-breakage-guard/acceptance-notes.md b/prepaid-compute-credit-breakage-guard/acceptance-notes.md new file mode 100644 index 00000000..a556a697 --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/acceptance-notes.md @@ -0,0 +1,18 @@ +# Acceptance Notes + +This contribution intentionally does not create live payment links, invoices, subscriptions, or provider integrations. It is a self-contained control module that can be reviewed without credentials. + +Acceptance checks: + +- `npm run check` validates all JavaScript files. +- `npm test` covers breakage-ready lots, missing-notice holds, refund liabilities, expiring-soon notices, protected grant credits, active usable credits, and deterministic audit digests. +- `npm run demo` writes reviewer artifacts to `reports/`. +- The PR includes a short demo video generated from the SVG summary. + +Non-overlap: + +- Not a generic billing ledger. +- Not a usage metering/idempotency guard. +- Not payment webhook or payment rail failover logic. +- Not collections, dispute evidence, tax exemption, plan-proration, storage overage, or account-transfer work. +- Focused only on prepaid AI compute credit breakage and expiration controls. diff --git a/prepaid-compute-credit-breakage-guard/demo.js b/prepaid-compute-credit-breakage-guard/demo.js new file mode 100644 index 00000000..8de1451d --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/demo.js @@ -0,0 +1,32 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + analyzeCreditBreakage, + renderMarkdownReport, + renderSvgSummary, +} = require("./index"); +const { sampleCreditLots } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const result = analyzeCreditBreakage(sampleCreditLots, { + asOf: "2026-05-22T12:00:00.000Z", + expiringSoonDays: 30, +}); + +fs.writeFileSync( + path.join(reportsDir, "summary.json"), + `${JSON.stringify(result, null, 2)}\n` +); +fs.writeFileSync( + path.join(reportsDir, "reviewer-packet.md"), + renderMarkdownReport(result) +); +fs.writeFileSync( + path.join(reportsDir, "summary.svg"), + renderSvgSummary(result) +); + +console.log("prepaid compute credit breakage guard demo artifacts written"); +console.log(`audit digest: ${result.auditDigest}`); diff --git a/prepaid-compute-credit-breakage-guard/index.js b/prepaid-compute-credit-breakage-guard/index.js new file mode 100644 index 00000000..7246f11d --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/index.js @@ -0,0 +1,354 @@ +const crypto = require("node:crypto"); + +const DAY_MS = 24 * 60 * 60 * 1000; + +function parseDate(value, field) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`invalid date for ${field}`); + } + return date; +} + +function remainingCents(lot) { + return Math.max( + 0, + Number(lot.originalCents || 0) - + Number(lot.usedCents || 0) - + Number(lot.refundedCents || 0) + ); +} + +function requireLotFields(lot) { + const required = [ + "id", + "customerName", + "issuedAt", + "expiresAt", + "originalCents", + "usedCents", + "terms", + ]; + + for (const field of required) { + if (lot[field] === undefined || lot[field] === null || lot[field] === "") { + throw new Error(`missing required credit lot field: ${field}`); + } + } +} + +function evaluateLot(lot, options) { + requireLotFields(lot); + + const asOf = parseDate(options.asOf, "asOf"); + const expiresAt = parseDate(lot.expiresAt, `expiresAt for ${lot.id}`); + const balanceCents = remainingCents(lot); + const daysUntilExpiry = Math.ceil((expiresAt.getTime() - asOf.getTime()) / DAY_MS); + const terms = lot.terms || {}; + const reasons = []; + const actions = []; + + const base = { + id: lot.id, + customerName: lot.customerName, + creditType: lot.creditType || "ai_compute_top_up", + issuedAt: lot.issuedAt, + expiresAt: lot.expiresAt, + balanceCents, + daysUntilExpiry, + recognizableBreakageCents: 0, + refundLiabilityCents: 0, + protectedBalanceCents: 0, + reasons, + actions, + }; + + if (balanceCents === 0) { + return { + ...base, + status: "depleted", + reasons: ["credit lot has no remaining balance"], + actions: ["No revenue action required"], + }; + } + + if (terms.grantFunded || terms.noExpiration || terms.expirationProhibited) { + reasons.push( + terms.grantFunded + ? "grant or contract terms prohibit expiration" + : "credit terms do not permit expiration" + ); + actions.push( + "Carry balance forward", + "Exclude from automated breakage recognition", + "Attach grant or customer contract evidence" + ); + return { + ...base, + status: "protected", + protectedBalanceCents: balanceCents, + actions, + }; + } + + if (lot.refundWindowOpen || lot.activeRefundRequest) { + reasons.push( + lot.activeRefundRequest + ? "customer refund request is active" + : "refund window remains open" + ); + actions.push( + "Reserve as refund liability", + "Do not recognize breakage until refund rights close", + "Route to finance review" + ); + return { + ...base, + status: "refund_liability", + refundLiabilityCents: balanceCents, + actions, + }; + } + + if (daysUntilExpiry > options.expiringSoonDays) { + reasons.push("credit lot remains active"); + actions.push( + "Keep credits usable", + "Monitor drawdown before the next close cycle" + ); + return { + ...base, + status: "usable", + actions, + }; + } + + if (daysUntilExpiry >= 0) { + reasons.push("credit lot is approaching expiration"); + actions.push( + "Send expiration notice before recognizing any breakage", + "Keep credits usable through the expiration deadline" + ); + return { + ...base, + status: "notice_due", + actions, + }; + } + + if (!terms.allowsBreakageRecognition) { + reasons.push("terms do not explicitly allow breakage recognition"); + } + + if (!lot.noticeSentAt) { + reasons.push("customer notice evidence is missing"); + } + + if (!lot.refundWindowClosed) { + reasons.push("refund window closure evidence is missing"); + } + + if (lot.activeDispute) { + reasons.push("active dispute prevents revenue close"); + } + + if (reasons.length > 0) { + actions.push( + "Hold balance out of recognized revenue", + "Send final notice or collect missing evidence", + "Route to finance review before close" + ); + return { + ...base, + status: "finance_hold", + actions, + }; + } + + return { + ...base, + status: "breakage_ready", + recognizableBreakageCents: balanceCents, + reasons: [ + "credit expired", + "terms allow breakage recognition", + "notice and refund closure evidence are present", + ], + actions: [ + "Recognize breakage after finance approval", + "Attach customer notice and terms evidence", + "Record audit digest in revenue close packet", + ], + }; +} + +function analyzeCreditBreakage(lots, options = {}) { + if (!Array.isArray(lots)) { + throw new Error("credit lots must be an array"); + } + + const normalizedOptions = { + asOf: parseDate(options.asOf || new Date().toISOString(), "asOf").toISOString(), + expiringSoonDays: Number(options.expiringSoonDays ?? 30), + }; + + const evaluatedLots = lots.map((lot) => evaluateLot(lot, normalizedOptions)); + const totals = evaluatedLots.reduce( + (acc, lot) => { + acc.openBalanceCents += lot.balanceCents; + acc.breakageEligibleCents += lot.recognizableBreakageCents; + acc.refundLiabilityCents += lot.refundLiabilityCents; + acc.protectedBalanceCents += lot.protectedBalanceCents; + + if (lot.status === "notice_due") { + acc.noticeDueCents += lot.balanceCents; + } + if (lot.status === "finance_hold") { + acc.financeHoldCents += lot.balanceCents; + } + if (lot.status === "usable") { + acc.usableBalanceCents += lot.balanceCents; + } + return acc; + }, + { + openBalanceCents: 0, + breakageEligibleCents: 0, + noticeDueCents: 0, + refundLiabilityCents: 0, + financeHoldCents: 0, + protectedBalanceCents: 0, + usableBalanceCents: 0, + } + ); + + const result = { + asOf: normalizedOptions.asOf, + expiringSoonDays: normalizedOptions.expiringSoonDays, + totals, + lots: evaluatedLots, + }; + + return { + ...result, + auditDigest: createAuditDigest(result), + }; +} + +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); +} + +function createAuditDigest(result) { + const payload = { + asOf: result.asOf, + totals: result.totals, + lots: result.lots.map((lot) => ({ + id: lot.id, + status: lot.status, + balanceCents: lot.balanceCents, + recognizableBreakageCents: lot.recognizableBreakageCents, + refundLiabilityCents: lot.refundLiabilityCents, + protectedBalanceCents: lot.protectedBalanceCents, + reasons: lot.reasons, + })), + }; + + return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex"); +} + +function formatDollars(cents) { + return `$${(cents / 100).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +function renderMarkdownReport(result) { + const rows = result.lots + .map( + (lot) => + `| ${lot.id} | ${lot.status} | ${formatDollars(lot.balanceCents)} | ${formatDollars( + lot.recognizableBreakageCents + )} | ${lot.actions[0]} |` + ) + .join("\n"); + + return `# Prepaid Compute Credit Breakage Review + +As of: ${result.asOf} +Audit digest: ${result.auditDigest} + +## Revenue Close Totals + +- Open balance: ${formatDollars(result.totals.openBalanceCents)} +- Breakage eligible: ${formatDollars(result.totals.breakageEligibleCents)} +- Notice due: ${formatDollars(result.totals.noticeDueCents)} +- Refund liability: ${formatDollars(result.totals.refundLiabilityCents)} +- Finance hold: ${formatDollars(result.totals.financeHoldCents)} +- Protected balance: ${formatDollars(result.totals.protectedBalanceCents)} +- Currently usable: ${formatDollars(result.totals.usableBalanceCents)} + +## Lot Decisions + +| Lot | Status | Balance | Recognizable | First action | +| --- | --- | ---: | ---: | --- | +${rows} +`; +} + +function renderSvgSummary(result) { + const statusColors = { + breakage_ready: "#15803d", + notice_due: "#d97706", + refund_liability: "#b91c1c", + finance_hold: "#7c3aed", + protected: "#0369a1", + usable: "#4b5563", + depleted: "#9ca3af", + }; + + const bars = result.lots + .map((lot, index) => { + const width = Math.max(32, Math.round((lot.balanceCents / result.totals.openBalanceCents) * 780)); + const y = 230 + index * 58; + const color = statusColors[lot.status] || "#4b5563"; + return ` + ${lot.id} + + ${lot.status} ${formatDollars(lot.balanceCents)} +`; + }) + .join("\n"); + + return ` + + Prepaid Compute Credit Breakage Guard + Revenue close review for AI compute top-up credits + + + Open ${formatDollars(result.totals.openBalanceCents)} | Breakage ${formatDollars(result.totals.breakageEligibleCents)} | Holds ${formatDollars(result.totals.financeHoldCents)} | Refunds ${formatDollars(result.totals.refundLiabilityCents)} + ${bars} + Audit digest: ${result.auditDigest} + + +`; +} + +module.exports = { + analyzeCreditBreakage, + createAuditDigest, + renderMarkdownReport, + renderSvgSummary, +}; diff --git a/prepaid-compute-credit-breakage-guard/package.json b/prepaid-compute-credit-breakage-guard/package.json new file mode 100644 index 00000000..5a62a47b --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "prepaid-compute-credit-breakage-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js" + } +} diff --git a/prepaid-compute-credit-breakage-guard/reports/demo.mp4 b/prepaid-compute-credit-breakage-guard/reports/demo.mp4 new file mode 100644 index 00000000..6071f73f Binary files /dev/null and b/prepaid-compute-credit-breakage-guard/reports/demo.mp4 differ diff --git a/prepaid-compute-credit-breakage-guard/reports/reviewer-packet.md b/prepaid-compute-credit-breakage-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..d1f99d12 --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/reports/reviewer-packet.md @@ -0,0 +1,26 @@ +# Prepaid Compute Credit Breakage Review + +As of: 2026-05-22T12:00:00.000Z +Audit digest: efffbafb34aec9e909ee2113672b18b88840ae580c37ef37207a2c7c8308a015 + +## Revenue Close Totals + +- Open balance: $7,900.00 +- Breakage eligible: $1,250.00 +- Notice due: $1,800.00 +- Refund liability: $500.00 +- Finance hold: $2,450.00 +- Protected balance: $1,100.00 +- Currently usable: $800.00 + +## Lot Decisions + +| Lot | Status | Balance | Recognizable | First action | +| --- | --- | ---: | ---: | --- | +| lot-expired-eligible | breakage_ready | $1,250.00 | $1,250.00 | Recognize breakage after finance approval | +| lot-expired-no-notice | finance_hold | $950.00 | $0.00 | Hold balance out of recognized revenue | +| lot-refund-window-open | refund_liability | $500.00 | $0.00 | Reserve as refund liability | +| lot-expiring-soon | notice_due | $1,800.00 | $0.00 | Send expiration notice before recognizing any breakage | +| lot-grant-no-expiry | protected | $1,100.00 | $0.00 | Carry balance forward | +| lot-active-future | usable | $800.00 | $0.00 | Keep credits usable | +| lot-dispute-hold | finance_hold | $1,500.00 | $0.00 | Hold balance out of recognized revenue | diff --git a/prepaid-compute-credit-breakage-guard/reports/summary.json b/prepaid-compute-credit-breakage-guard/reports/summary.json new file mode 100644 index 00000000..a4e39e6d --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/reports/summary.json @@ -0,0 +1,163 @@ +{ + "asOf": "2026-05-22T12:00:00.000Z", + "expiringSoonDays": 30, + "totals": { + "openBalanceCents": 790000, + "breakageEligibleCents": 125000, + "noticeDueCents": 180000, + "refundLiabilityCents": 50000, + "financeHoldCents": 245000, + "protectedBalanceCents": 110000, + "usableBalanceCents": 80000 + }, + "lots": [ + { + "id": "lot-expired-eligible", + "customerName": "North Valley Lab", + "creditType": "ai_compute_top_up", + "issuedAt": "2025-11-15T00:00:00.000Z", + "expiresAt": "2026-04-15T00:00:00.000Z", + "balanceCents": 125000, + "daysUntilExpiry": -37, + "recognizableBreakageCents": 125000, + "refundLiabilityCents": 0, + "protectedBalanceCents": 0, + "reasons": [ + "credit expired", + "terms allow breakage recognition", + "notice and refund closure evidence are present" + ], + "actions": [ + "Recognize breakage after finance approval", + "Attach customer notice and terms evidence", + "Record audit digest in revenue close packet" + ], + "status": "breakage_ready" + }, + { + "id": "lot-expired-no-notice", + "customerName": "Bioinformatics Core", + "creditType": "ai_compute_top_up", + "issuedAt": "2025-10-01T00:00:00.000Z", + "expiresAt": "2026-04-20T00:00:00.000Z", + "balanceCents": 95000, + "daysUntilExpiry": -32, + "recognizableBreakageCents": 0, + "refundLiabilityCents": 0, + "protectedBalanceCents": 0, + "reasons": [ + "customer notice evidence is missing" + ], + "actions": [ + "Hold balance out of recognized revenue", + "Send final notice or collect missing evidence", + "Route to finance review before close" + ], + "status": "finance_hold" + }, + { + "id": "lot-refund-window-open", + "customerName": "Quantum Methods Group", + "creditType": "ai_compute_top_up", + "issuedAt": "2026-01-10T00:00:00.000Z", + "expiresAt": "2026-04-01T00:00:00.000Z", + "balanceCents": 50000, + "daysUntilExpiry": -51, + "recognizableBreakageCents": 0, + "refundLiabilityCents": 50000, + "protectedBalanceCents": 0, + "reasons": [ + "refund window remains open" + ], + "actions": [ + "Reserve as refund liability", + "Do not recognize breakage until refund rights close", + "Route to finance review" + ], + "status": "refund_liability" + }, + { + "id": "lot-expiring-soon", + "customerName": "Genomics Pilot Program", + "creditType": "ai_compute_top_up", + "issuedAt": "2026-02-01T00:00:00.000Z", + "expiresAt": "2026-06-02T00:00:00.000Z", + "balanceCents": 180000, + "daysUntilExpiry": 11, + "recognizableBreakageCents": 0, + "refundLiabilityCents": 0, + "protectedBalanceCents": 0, + "reasons": [ + "credit lot is approaching expiration" + ], + "actions": [ + "Send expiration notice before recognizing any breakage", + "Keep credits usable through the expiration deadline" + ], + "status": "notice_due" + }, + { + "id": "lot-grant-no-expiry", + "customerName": "NIH Shared Instrumentation Award", + "creditType": "grant_compute_credit", + "issuedAt": "2025-12-01T00:00:00.000Z", + "expiresAt": "2026-05-01T00:00:00.000Z", + "balanceCents": 110000, + "daysUntilExpiry": -21, + "recognizableBreakageCents": 0, + "refundLiabilityCents": 0, + "protectedBalanceCents": 110000, + "reasons": [ + "grant or contract terms prohibit expiration" + ], + "actions": [ + "Carry balance forward", + "Exclude from automated breakage recognition", + "Attach grant or customer contract evidence" + ], + "status": "protected" + }, + { + "id": "lot-active-future", + "customerName": "Climate Modeling Center", + "creditType": "ai_compute_top_up", + "issuedAt": "2026-05-01T00:00:00.000Z", + "expiresAt": "2026-08-30T00:00:00.000Z", + "balanceCents": 80000, + "daysUntilExpiry": 100, + "recognizableBreakageCents": 0, + "refundLiabilityCents": 0, + "protectedBalanceCents": 0, + "reasons": [ + "credit lot remains active" + ], + "actions": [ + "Keep credits usable", + "Monitor drawdown before the next close cycle" + ], + "status": "usable" + }, + { + "id": "lot-dispute-hold", + "customerName": "Materials Discovery Consortium", + "creditType": "ai_compute_top_up", + "issuedAt": "2025-09-15T00:00:00.000Z", + "expiresAt": "2026-04-10T00:00:00.000Z", + "balanceCents": 150000, + "daysUntilExpiry": -42, + "recognizableBreakageCents": 0, + "refundLiabilityCents": 0, + "protectedBalanceCents": 0, + "reasons": [ + "active dispute prevents revenue close" + ], + "actions": [ + "Hold balance out of recognized revenue", + "Send final notice or collect missing evidence", + "Route to finance review before close" + ], + "status": "finance_hold" + } + ], + "auditDigest": "efffbafb34aec9e909ee2113672b18b88840ae580c37ef37207a2c7c8308a015" +} diff --git a/prepaid-compute-credit-breakage-guard/reports/summary.svg b/prepaid-compute-credit-breakage-guard/reports/summary.svg new file mode 100644 index 00000000..53c014c6 --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/reports/summary.svg @@ -0,0 +1,45 @@ + + + Prepaid Compute Credit Breakage Guard + Revenue close review for AI compute top-up credits + + + Open $7,900.00 | Breakage $1,250.00 | Holds $2,450.00 | Refunds $500.00 + + lot-expired-eligible + + breakage_ready $1,250.00 + + + lot-expired-no-notice + + finance_hold $950.00 + + + lot-refund-window-open + + refund_liability $500.00 + + + lot-expiring-soon + + notice_due $1,800.00 + + + lot-grant-no-expiry + + protected $1,100.00 + + + lot-active-future + + usable $800.00 + + + lot-dispute-hold + + finance_hold $1,500.00 + + Audit digest: efffbafb34aec9e909ee2113672b18b88840ae580c37ef37207a2c7c8308a015 + + diff --git a/prepaid-compute-credit-breakage-guard/requirements-map.md b/prepaid-compute-credit-breakage-guard/requirements-map.md new file mode 100644 index 00000000..9b1f7071 --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/requirements-map.md @@ -0,0 +1,11 @@ +# Requirements Map + +Issue #20 describes Revenue Infrastructure for subscription billing, AI compute billing, top-ups, institutional invoices, and licensing revenue. This slice focuses on the AI compute billing/top-up close process. + +| Issue requirement | Implementation coverage | +| --- | --- | +| AI compute billing and top-ups | Models prepaid AI compute credit lots, usage drawdown, expiration, and breakage review. | +| Transparent quotas and usage meters | Keeps active usable balances separate from expired breakage candidates. | +| Billing engine controls | Requires explicit terms, customer notices, refund-window closure, and dispute checks before recognizing breakage. | +| Audit-ready revenue operations | Emits deterministic JSON, Markdown, SVG, and audit digest artifacts for finance review. | +| Safe payment/revenue handling | Uses synthetic data only and performs no live payment, Stripe, PayPal, bank, or credential operations. | diff --git a/prepaid-compute-credit-breakage-guard/sample-data.js b/prepaid-compute-credit-breakage-guard/sample-data.js new file mode 100644 index 00000000..4cf24a7a --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/sample-data.js @@ -0,0 +1,115 @@ +const sampleCreditLots = [ + { + id: "lot-expired-eligible", + customerName: "North Valley Lab", + creditType: "ai_compute_top_up", + issuedAt: "2025-11-15T00:00:00.000Z", + expiresAt: "2026-04-15T00:00:00.000Z", + originalCents: 250000, + usedCents: 125000, + refundedCents: 0, + noticeSentAt: "2026-03-01T10:00:00.000Z", + refundWindowClosed: true, + terms: { + allowsBreakageRecognition: true, + }, + }, + { + id: "lot-expired-no-notice", + customerName: "Bioinformatics Core", + creditType: "ai_compute_top_up", + issuedAt: "2025-10-01T00:00:00.000Z", + expiresAt: "2026-04-20T00:00:00.000Z", + originalCents: 120000, + usedCents: 25000, + refundedCents: 0, + noticeSentAt: null, + refundWindowClosed: true, + terms: { + allowsBreakageRecognition: true, + }, + }, + { + id: "lot-refund-window-open", + customerName: "Quantum Methods Group", + creditType: "ai_compute_top_up", + issuedAt: "2026-01-10T00:00:00.000Z", + expiresAt: "2026-04-01T00:00:00.000Z", + originalCents: 75000, + usedCents: 25000, + refundedCents: 0, + noticeSentAt: "2026-03-10T10:00:00.000Z", + refundWindowOpen: true, + refundWindowClosed: false, + terms: { + allowsBreakageRecognition: true, + }, + }, + { + id: "lot-expiring-soon", + customerName: "Genomics Pilot Program", + creditType: "ai_compute_top_up", + issuedAt: "2026-02-01T00:00:00.000Z", + expiresAt: "2026-06-02T00:00:00.000Z", + originalCents: 240000, + usedCents: 60000, + refundedCents: 0, + noticeSentAt: null, + refundWindowClosed: false, + terms: { + allowsBreakageRecognition: true, + }, + }, + { + id: "lot-grant-no-expiry", + customerName: "NIH Shared Instrumentation Award", + creditType: "grant_compute_credit", + issuedAt: "2025-12-01T00:00:00.000Z", + expiresAt: "2026-05-01T00:00:00.000Z", + originalCents: 200000, + usedCents: 90000, + refundedCents: 0, + noticeSentAt: null, + refundWindowClosed: false, + terms: { + grantFunded: true, + noExpiration: true, + allowsBreakageRecognition: false, + }, + }, + { + id: "lot-active-future", + customerName: "Climate Modeling Center", + creditType: "ai_compute_top_up", + issuedAt: "2026-05-01T00:00:00.000Z", + expiresAt: "2026-08-30T00:00:00.000Z", + originalCents: 125000, + usedCents: 45000, + refundedCents: 0, + noticeSentAt: null, + refundWindowClosed: false, + terms: { + allowsBreakageRecognition: true, + }, + }, + { + id: "lot-dispute-hold", + customerName: "Materials Discovery Consortium", + creditType: "ai_compute_top_up", + issuedAt: "2025-09-15T00:00:00.000Z", + expiresAt: "2026-04-10T00:00:00.000Z", + originalCents: 190000, + usedCents: 40000, + refundedCents: 0, + noticeSentAt: "2026-03-01T10:00:00.000Z", + refundWindowClosed: true, + activeDispute: true, + terms: { + allowsBreakageRecognition: true, + }, + }, +]; + +module.exports = { + sampleCreditLots, +}; diff --git a/prepaid-compute-credit-breakage-guard/test.js b/prepaid-compute-credit-breakage-guard/test.js new file mode 100644 index 00000000..fe06c849 --- /dev/null +++ b/prepaid-compute-credit-breakage-guard/test.js @@ -0,0 +1,78 @@ +const assert = require("node:assert/strict"); +const { + analyzeCreditBreakage, + createAuditDigest, +} = require("./index"); +const { sampleCreditLots } = require("./sample-data"); + +const AS_OF = "2026-05-22T12:00:00.000Z"; + +function byId(result, id) { + const lot = result.lots.find((entry) => entry.id === id); + assert.ok(lot, `expected lot ${id} to exist`); + return lot; +} + +function run() { + const result = analyzeCreditBreakage(sampleCreditLots, { + asOf: AS_OF, + expiringSoonDays: 30, + }); + + assert.equal(result.asOf, AS_OF); + assert.equal(result.totals.openBalanceCents, 790000); + assert.equal(result.totals.usableBalanceCents, 80000); + assert.equal(result.totals.breakageEligibleCents, 125000); + assert.equal(result.totals.noticeDueCents, 180000); + assert.equal(result.totals.refundLiabilityCents, 50000); + assert.equal(result.totals.financeHoldCents, 245000); + assert.equal(result.totals.protectedBalanceCents, 110000); + + const eligible = byId(result, "lot-expired-eligible"); + assert.equal(eligible.status, "breakage_ready"); + assert.equal(eligible.recognizableBreakageCents, 125000); + assert.deepEqual(eligible.actions, [ + "Recognize breakage after finance approval", + "Attach customer notice and terms evidence", + "Record audit digest in revenue close packet", + ]); + + const noNotice = byId(result, "lot-expired-no-notice"); + assert.equal(noNotice.status, "finance_hold"); + assert.equal(noNotice.recognizableBreakageCents, 0); + assert.ok(noNotice.reasons.includes("customer notice evidence is missing")); + + const refund = byId(result, "lot-refund-window-open"); + assert.equal(refund.status, "refund_liability"); + assert.equal(refund.refundLiabilityCents, 50000); + assert.equal(refund.recognizableBreakageCents, 0); + + const expiring = byId(result, "lot-expiring-soon"); + assert.equal(expiring.status, "notice_due"); + assert.equal(expiring.daysUntilExpiry, 11); + assert.deepEqual(expiring.actions, [ + "Send expiration notice before recognizing any breakage", + "Keep credits usable through the expiration deadline", + ]); + + const grant = byId(result, "lot-grant-no-expiry"); + assert.equal(grant.status, "protected"); + assert.equal(grant.protectedBalanceCents, 110000); + assert.ok(grant.reasons.includes("grant or contract terms prohibit expiration")); + + const future = byId(result, "lot-active-future"); + assert.equal(future.status, "usable"); + assert.equal(future.recognizableBreakageCents, 0); + + const digest = createAuditDigest(result); + assert.match(digest, /^[a-f0-9]{64}$/); + assert.equal(digest, createAuditDigest(result)); + + assert.throws( + () => analyzeCreditBreakage([{ id: "bad", originalCents: 1 }], { asOf: AS_OF }), + /missing required credit lot field/ + ); +} + +run(); +console.log("prepaid compute credit breakage guard tests passed");