diff --git a/subscription-quota-rollover-guard/README.md b/subscription-quota-rollover-guard/README.md new file mode 100644 index 00000000..195e9ebb --- /dev/null +++ b/subscription-quota-rollover-guard/README.md @@ -0,0 +1,38 @@ +# Subscription Quota Rollover Guard + +This slice adds a dependency-free Revenue Infrastructure guard for subscription AI-compute or token quota rollover. It audits synthetic quota ledgers before invoice release so SCIBASE can avoid double billing, expired quota application, and unsupported carry-forward during plan changes. + +The scope is intentionally distinct from existing same-issue submissions for proration, renewal notice, entitlement downgrade, usage replay/idempotency, prepaid credit breakage, committed drawdown, quote approval, invoice delivery, tax, collections, analytics-seat leakage, and payment authorization freshness. + +## What It Checks + +- Carry-forward units exceeding the plan rollover cap. +- Posted rollover totals that do not match prior-cycle unused quota. +- Duplicate rollover lot identifiers. +- Expired rollover lots applied to the current invoice. +- Applied units that do not reconcile to lot-level records. +- Plan downgrade carry limits. +- Unapproved negative quota adjustments. +- Overage line items that fail to subtract applied rollover. +- Missing finance hold when blockers exist. + +## Reviewer Output + +Running the demo creates: + +- `reports/quota-rollover-report.json` +- `reports/quota-rollover-report.md` +- `reports/summary.svg` +- `reports/demo-script.txt` +- `reports/demo.gif` +- `reports/demo.mp4` + +All inputs are synthetic. The guard uses no credentials, private customer data, payment processors, live billing systems, analytics APIs, or external services. + +## Commands + +```bash +npm test +npm run demo +npm run demo:video +``` diff --git a/subscription-quota-rollover-guard/demo.js b/subscription-quota-rollover-guard/demo.js new file mode 100644 index 00000000..be74ac30 --- /dev/null +++ b/subscription-quota-rollover-guard/demo.js @@ -0,0 +1,25 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { auditQuotaRollover, buildFinanceMarkdown, buildSummarySvg } from "./index.js"; +import { riskyQuotaLedger } from "./sample-data.js"; + +const reportsDir = new URL("./reports/", import.meta.url); +await mkdir(reportsDir, { recursive: true }); + +const report = auditQuotaRollover(riskyQuotaLedger); +await writeFile(new URL("quota-rollover-report.json", reportsDir), `${JSON.stringify(report, null, 2)}\n`); +await writeFile(new URL("quota-rollover-report.md", reportsDir), buildFinanceMarkdown(report)); +await writeFile(new URL("summary.svg", reportsDir), buildSummarySvg(report)); +await writeFile( + new URL("demo-script.txt", reportsDir), + [ + "Demo: Subscription Quota Rollover Guard", + `Account: ${report.accountId}`, + `Billing cycle: ${report.billingCycle}`, + `Decision: ${report.decision}`, + `Risk score: ${report.riskScore}/100`, + `Blockers: ${report.summary.blockCount}`, + "Finance action: hold invoice until duplicate/expired rollover, downgrade caps, and overage units reconcile.", + ].join("\n"), +); + +console.log(JSON.stringify(report.summary, null, 2)); diff --git a/subscription-quota-rollover-guard/demo_video.py b/subscription-quota-rollover-guard/demo_video.py new file mode 100644 index 00000000..2d77ce71 --- /dev/null +++ b/subscription-quota-rollover-guard/demo_video.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import imageio.v3 as iio +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +REPORTS.mkdir(exist_ok=True) + + +def font(size): + for name in ("arial.ttf", "segoeui.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + pass + return ImageFont.load_default() + + +slides = [ + ("Subscription Quota Rollover Guard", "Synthetic revenue control for SCIBASE #20"), + ("Decision", "hold-invoice · risk score 100/100"), + ("Detected", "expired lots · duplicate carry-forward · downgrade cap breach"), + ("Finance Action", "Hold invoice and reconcile overage before release"), +] + +frames = [] +for title, subtitle in slides: + image = Image.new("RGB", (960, 540), "#111827") + draw = ImageDraw.Draw(image) + draw.rectangle((48, 58, 912, 482), outline="#374151", width=3) + draw.text((82, 132), title, fill="#f9fafb", font=font(42)) + draw.text((82, 214), subtitle, fill="#d1d5db", font=font(24)) + draw.rectangle((82, 340, 690, 380), fill="#dc2626") + draw.text((82, 410), "Synthetic data only. No payment processor or live billing calls.", fill="#9ca3af", font=font(20)) + frames.extend([image] * 14) + +gif_path = REPORTS / "demo.gif" +frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0) +mp4_path = REPORTS / "demo.mp4" +iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264") +print(f"wrote {gif_path}") +print(f"wrote {mp4_path}") diff --git a/subscription-quota-rollover-guard/index.js b/subscription-quota-rollover-guard/index.js new file mode 100644 index 00000000..d23539e9 --- /dev/null +++ b/subscription-quota-rollover-guard/index.js @@ -0,0 +1,254 @@ +const SEVERITY_ORDER = { block: 3, warn: 2, info: 1 }; + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function cycleToNumber(cycle) { + const match = String(cycle || "").match(/^(\d{4})-(\d{2})$/); + if (!match) return Number.NaN; + return Number(match[1]) * 12 + Number(match[2]); +} + +function sum(values) { + return values.reduce((total, value) => total + Number(value || 0), 0); +} + +export function auditQuotaRollover(ledger) { + if (!ledger || typeof ledger !== "object") { + throw new TypeError("quota ledger must be an object"); + } + + const issues = []; + const plan = ledger.plan || {}; + const prior = ledger.priorCycle || {}; + const current = ledger.currentCycle || {}; + const lots = ledger.rolloverLots || []; + const adjustments = ledger.adjustments || []; + const invoice = ledger.invoicePreview || {}; + + if (prior.carryForwardUnits > plan.rolloverCapUnits) { + issues.push( + finding( + "ROLLOVER_CAP_EXCEEDED", + "block", + "Prior-cycle unused quota exceeds the contractual rollover cap.", + { + carryForwardUnits: prior.carryForwardUnits, + rolloverCapUnits: plan.rolloverCapUnits, + }, + "Clamp carry-forward units to the plan cap and regenerate the invoice preview before release.", + ), + ); + } + + if (prior.postedRolloverUnits !== prior.carryForwardUnits) { + issues.push( + finding( + "POSTED_ROLLOVER_MISMATCH", + "block", + "Posted rollover units do not match the calculated prior-cycle carry-forward units.", + { + postedRolloverUnits: prior.postedRolloverUnits, + carryForwardUnits: prior.carryForwardUnits, + }, + "Reconcile the quota ledger before overage or credit lines are posted.", + ), + ); + } + + const seenLots = new Set(); + for (const lot of lots) { + if (seenLots.has(lot.lotId)) { + issues.push( + finding( + "DUPLICATE_ROLLOVER_LOT", + "block", + "The same rollover lot appears more than once and could be carried forward twice.", + { lotId: lot.lotId }, + "Deduplicate rollover lots by immutable lot id before invoice generation.", + ), + ); + } + seenLots.add(lot.lotId); + + if (cycleToNumber(lot.expiresAfterCycle) < cycleToNumber(ledger.billingCycle) && lot.appliedUnits > 0) { + issues.push( + finding( + "EXPIRED_ROLLOVER_APPLIED", + "block", + "Expired quota was applied to the current invoice.", + { + lotId: lot.lotId, + expiresAfterCycle: lot.expiresAfterCycle, + billingCycle: ledger.billingCycle, + appliedUnits: lot.appliedUnits, + }, + "Remove expired lots from available balance and route the invoice to finance review.", + ), + ); + } + } + + const totalAppliedFromLots = sum(lots.map((lot) => lot.appliedUnits)); + if (totalAppliedFromLots !== current.rolloverAppliedUnits) { + issues.push( + finding( + "ROLLOVER_APPLICATION_MISMATCH", + "block", + "Applied rollover units do not equal the sum of applied rollover lots.", + { + totalAppliedFromLots, + rolloverAppliedUnits: current.rolloverAppliedUnits, + }, + "Regenerate applied quota from lot-level records instead of invoice-line totals.", + ), + ); + } + + if (current.planChangedFrom !== current.planChangedTo && current.rolloverAppliedUnits > plan.downgradeCarryLimitUnits) { + issues.push( + finding( + "DOWNGRADE_CARRY_LIMIT_EXCEEDED", + "block", + "A downgraded account is applying more carried quota than the downgrade policy allows.", + { + planChangedFrom: current.planChangedFrom, + planChangedTo: current.planChangedTo, + rolloverAppliedUnits: current.rolloverAppliedUnits, + downgradeCarryLimitUnits: plan.downgradeCarryLimitUnits, + }, + "Apply the downgrade carry limit or hold the invoice for contract review.", + ), + ); + } + + const unapprovedNegativeAdjustments = adjustments.filter((adjustment) => adjustment.units < 0 && !adjustment.approvedByFinance); + if (unapprovedNegativeAdjustments.length > 0) { + issues.push( + finding( + "UNAPPROVED_NEGATIVE_ADJUSTMENT", + "warn", + "Negative quota adjustments exist without finance approval.", + { adjustmentIds: unapprovedNegativeAdjustments.map((adjustment) => adjustment.adjustmentId) }, + "Require finance approval before negative units affect quota or invoice totals.", + ), + ); + } + + const expectedOverageUnits = Math.max(0, current.usageUnits - current.includedUnits - current.rolloverAppliedUnits); + if (invoice.lineItems?.some((line) => line.code === "ai-compute-overage") && current.invoiceOverageUnits !== expectedOverageUnits) { + issues.push( + finding( + "OVERAGE_DOUBLE_BILLING_RISK", + "block", + "Invoice overage units do not subtract applied rollover correctly.", + { + usageUnits: current.usageUnits, + includedUnits: current.includedUnits, + rolloverAppliedUnits: current.rolloverAppliedUnits, + invoiceOverageUnits: current.invoiceOverageUnits, + expectedOverageUnits, + }, + "Recompute overage after rollover application to prevent double billing.", + ), + ); + } + + if (issues.some((issue) => issue.severity === "block") && invoice.reviewerHold !== true) { + issues.push( + finding( + "MISSING_FINANCE_HOLD", + "block", + "The invoice preview is not on hold despite quota rollover blockers.", + { reviewerHold: invoice.reviewerHold }, + "Place the invoice on finance hold until quota carry-forward and overage lines reconcile.", + ), + ); + } + + const blockCount = issues.filter((issue) => issue.severity === "block").length; + const warnCount = issues.filter((issue) => issue.severity === "warn").length; + return { + accountId: ledger.accountId, + billingCycle: ledger.billingCycle, + decision: blockCount > 0 ? "hold-invoice" : warnCount > 0 ? "finance-review" : "ready-to-invoice", + riskScore: Math.min(100, blockCount * 24 + warnCount * 9), + summary: { + blockCount, + warnCount, + findingCount: issues.length, + appliedRolloverUnits: current.rolloverAppliedUnits || 0, + expectedOverageUnits, + }, + findings: issues.sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || a.code.localeCompare(b.code)), + }; +} + +export function buildFinanceMarkdown(report) { + const lines = [ + `# Subscription Quota Rollover Guard: ${report.accountId}`, + "", + `Billing cycle: **${report.billingCycle}**`, + `Decision: **${report.decision}**`, + `Risk score: **${report.riskScore}/100**`, + "", + `Findings: ${report.summary.blockCount} blockers, ${report.summary.warnCount} warnings.`, + `Applied rollover units: ${report.summary.appliedRolloverUnits}`, + `Expected overage units: ${report.summary.expectedOverageUnits}`, + "", + ]; + + for (const item of report.findings) { + lines.push(`## ${item.severity.toUpperCase()}: ${item.code}`); + lines.push(item.message); + lines.push(""); + lines.push(`Evidence: \`${JSON.stringify(item.evidence)}\``); + lines.push(""); + lines.push(`Remediation: ${item.remediation}`); + lines.push(""); + } + + if (report.findings.length === 0) { + lines.push("No rollover blockers or warnings were detected in the synthetic ledger."); + lines.push(""); + } + + return lines.join("\n"); +} + +export function buildSummarySvg(report) { + const color = report.decision === "hold-invoice" ? "#dc2626" : report.decision === "finance-review" ? "#d97706" : "#16a34a"; + return ` + + Subscription Quota Rollover Guard + ${escapeXml(report.accountId)} · ${escapeXml(report.billingCycle)} + + + ${report.riskScore}/100 + + + ${report.summary.blockCount} + invoice blockers + + + + ${report.summary.appliedRolloverUnits} + applied rollover units + + + + ${report.summary.expectedOverageUnits} + expected overage units + + +`; +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/subscription-quota-rollover-guard/package.json b/subscription-quota-rollover-guard/package.json new file mode 100644 index 00000000..08ef4c7c --- /dev/null +++ b/subscription-quota-rollover-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "subscription-quota-rollover-guard", + "version": "1.0.0", + "description": "Synthetic quota rollover audit guard for SCIBASE revenue infrastructure.", + "type": "module", + "private": true, + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "python demo_video.py" + } +} diff --git a/subscription-quota-rollover-guard/reports/demo-script.txt b/subscription-quota-rollover-guard/reports/demo-script.txt new file mode 100644 index 00000000..50600235 --- /dev/null +++ b/subscription-quota-rollover-guard/reports/demo-script.txt @@ -0,0 +1,7 @@ +Demo: Subscription Quota Rollover Guard +Account: LAB-QUOTA-1042 +Billing cycle: 2026-06 +Decision: hold-invoice +Risk score: 100/100 +Blockers: 6 +Finance action: hold invoice until duplicate/expired rollover, downgrade caps, and overage units reconcile. \ No newline at end of file diff --git a/subscription-quota-rollover-guard/reports/demo.gif b/subscription-quota-rollover-guard/reports/demo.gif new file mode 100644 index 00000000..1197c266 Binary files /dev/null and b/subscription-quota-rollover-guard/reports/demo.gif differ diff --git a/subscription-quota-rollover-guard/reports/demo.mp4 b/subscription-quota-rollover-guard/reports/demo.mp4 new file mode 100644 index 00000000..d4d3e9af Binary files /dev/null and b/subscription-quota-rollover-guard/reports/demo.mp4 differ diff --git a/subscription-quota-rollover-guard/reports/quota-rollover-report.json b/subscription-quota-rollover-guard/reports/quota-rollover-report.json new file mode 100644 index 00000000..8e5cf608 --- /dev/null +++ b/subscription-quota-rollover-guard/reports/quota-rollover-report.json @@ -0,0 +1,91 @@ +{ + "accountId": "LAB-QUOTA-1042", + "billingCycle": "2026-06", + "decision": "hold-invoice", + "riskScore": 100, + "summary": { + "blockCount": 6, + "warnCount": 1, + "findingCount": 7, + "appliedRolloverUnits": 3900, + "expectedOverageUnits": 1300 + }, + "findings": [ + { + "code": "DOWNGRADE_CARRY_LIMIT_EXCEEDED", + "severity": "block", + "message": "A downgraded account is applying more carried quota than the downgrade policy allows.", + "evidence": { + "planChangedFrom": "Lab Pro", + "planChangedTo": "Individual Pro", + "rolloverAppliedUnits": 3900, + "downgradeCarryLimitUnits": 1000 + }, + "remediation": "Apply the downgrade carry limit or hold the invoice for contract review." + }, + { + "code": "DUPLICATE_ROLLOVER_LOT", + "severity": "block", + "message": "The same rollover lot appears more than once and could be carried forward twice.", + "evidence": { + "lotId": "duplicate-may" + }, + "remediation": "Deduplicate rollover lots by immutable lot id before invoice generation." + }, + { + "code": "EXPIRED_ROLLOVER_APPLIED", + "severity": "block", + "message": "Expired quota was applied to the current invoice.", + "evidence": { + "lotId": "legacy-feb", + "expiresAfterCycle": "2026-04", + "billingCycle": "2026-06", + "appliedUnits": 900 + }, + "remediation": "Remove expired lots from available balance and route the invoice to finance review." + }, + { + "code": "MISSING_FINANCE_HOLD", + "severity": "block", + "message": "The invoice preview is not on hold despite quota rollover blockers.", + "evidence": { + "reviewerHold": false + }, + "remediation": "Place the invoice on finance hold until quota carry-forward and overage lines reconcile." + }, + { + "code": "OVERAGE_DOUBLE_BILLING_RISK", + "severity": "block", + "message": "Invoice overage units do not subtract applied rollover correctly.", + "evidence": { + "usageUnits": 11200, + "includedUnits": 6000, + "rolloverAppliedUnits": 3900, + "invoiceOverageUnits": 5200, + "expectedOverageUnits": 1300 + }, + "remediation": "Recompute overage after rollover application to prevent double billing." + }, + { + "code": "ROLLOVER_CAP_EXCEEDED", + "severity": "block", + "message": "Prior-cycle unused quota exceeds the contractual rollover cap.", + "evidence": { + "carryForwardUnits": 3900, + "rolloverCapUnits": 3000 + }, + "remediation": "Clamp carry-forward units to the plan cap and regenerate the invoice preview before release." + }, + { + "code": "UNAPPROVED_NEGATIVE_ADJUSTMENT", + "severity": "warn", + "message": "Negative quota adjustments exist without finance approval.", + "evidence": { + "adjustmentIds": [ + "manual-negative-1" + ] + }, + "remediation": "Require finance approval before negative units affect quota or invoice totals." + } + ] +} diff --git a/subscription-quota-rollover-guard/reports/quota-rollover-report.md b/subscription-quota-rollover-guard/reports/quota-rollover-report.md new file mode 100644 index 00000000..5e5bc0d4 --- /dev/null +++ b/subscription-quota-rollover-guard/reports/quota-rollover-report.md @@ -0,0 +1,58 @@ +# Subscription Quota Rollover Guard: LAB-QUOTA-1042 + +Billing cycle: **2026-06** +Decision: **hold-invoice** +Risk score: **100/100** + +Findings: 6 blockers, 1 warnings. +Applied rollover units: 3900 +Expected overage units: 1300 + +## BLOCK: DOWNGRADE_CARRY_LIMIT_EXCEEDED +A downgraded account is applying more carried quota than the downgrade policy allows. + +Evidence: `{"planChangedFrom":"Lab Pro","planChangedTo":"Individual Pro","rolloverAppliedUnits":3900,"downgradeCarryLimitUnits":1000}` + +Remediation: Apply the downgrade carry limit or hold the invoice for contract review. + +## BLOCK: DUPLICATE_ROLLOVER_LOT +The same rollover lot appears more than once and could be carried forward twice. + +Evidence: `{"lotId":"duplicate-may"}` + +Remediation: Deduplicate rollover lots by immutable lot id before invoice generation. + +## BLOCK: EXPIRED_ROLLOVER_APPLIED +Expired quota was applied to the current invoice. + +Evidence: `{"lotId":"legacy-feb","expiresAfterCycle":"2026-04","billingCycle":"2026-06","appliedUnits":900}` + +Remediation: Remove expired lots from available balance and route the invoice to finance review. + +## BLOCK: MISSING_FINANCE_HOLD +The invoice preview is not on hold despite quota rollover blockers. + +Evidence: `{"reviewerHold":false}` + +Remediation: Place the invoice on finance hold until quota carry-forward and overage lines reconcile. + +## BLOCK: OVERAGE_DOUBLE_BILLING_RISK +Invoice overage units do not subtract applied rollover correctly. + +Evidence: `{"usageUnits":11200,"includedUnits":6000,"rolloverAppliedUnits":3900,"invoiceOverageUnits":5200,"expectedOverageUnits":1300}` + +Remediation: Recompute overage after rollover application to prevent double billing. + +## BLOCK: ROLLOVER_CAP_EXCEEDED +Prior-cycle unused quota exceeds the contractual rollover cap. + +Evidence: `{"carryForwardUnits":3900,"rolloverCapUnits":3000}` + +Remediation: Clamp carry-forward units to the plan cap and regenerate the invoice preview before release. + +## WARN: UNAPPROVED_NEGATIVE_ADJUSTMENT +Negative quota adjustments exist without finance approval. + +Evidence: `{"adjustmentIds":["manual-negative-1"]}` + +Remediation: Require finance approval before negative units affect quota or invoice totals. diff --git a/subscription-quota-rollover-guard/reports/summary.svg b/subscription-quota-rollover-guard/reports/summary.svg new file mode 100644 index 00000000..debcf9e7 --- /dev/null +++ b/subscription-quota-rollover-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + + Subscription Quota Rollover Guard + LAB-QUOTA-1042 · 2026-06 + + + 100/100 + + + 6 + invoice blockers + + + + 3900 + applied rollover units + + + + 1300 + expected overage units + + diff --git a/subscription-quota-rollover-guard/requirements-map.md b/subscription-quota-rollover-guard/requirements-map.md new file mode 100644 index 00000000..5e59c41d --- /dev/null +++ b/subscription-quota-rollover-guard/requirements-map.md @@ -0,0 +1,29 @@ +# Requirements Map + +Issue #20 asks for revenue infrastructure across subscription billing, usage-based AI compute billing, real-time usage meters, quotas, top-ups, and licensing controls. + +This contribution covers a distinct revenue control: validating subscription quota rollover before invoice release. + +| Issue capability | Implementation | +| --- | --- | +| Tiered subscription billing | Audits included quota, rollover caps, downgrade carry limits, and plan-change behavior. | +| Usage-based AI compute billing | Reconciles usage units, applied rollover, and expected overage units. | +| Transparent quotas and real-time usage meters | Treats rollover lots and quota adjustments as auditable ledger records. | +| Billing safety before invoice release | Emits `hold-invoice`, `finance-review`, or `ready-to-invoice` decisions with remediation. | +| Reviewer-ready evidence | Generates JSON, Markdown, SVG, and GIF demo artifacts from synthetic data. | + +## Non-Overlap + +This is not: + +- Plan migration proration. +- Subscription renewal notice. +- Entitlement downgrade. +- Usage replay or idempotency. +- Prepaid credit breakage. +- Committed revenue drawdown. +- Quote approval. +- Invoice delivery or collections. +- Analytics-seat/API licensing. + +It specifically verifies whether unused monthly subscription quota is carried, expired, capped, adjusted, and applied correctly before overage charges are released. diff --git a/subscription-quota-rollover-guard/sample-data.js b/subscription-quota-rollover-guard/sample-data.js new file mode 100644 index 00000000..c53b61b0 --- /dev/null +++ b/subscription-quota-rollover-guard/sample-data.js @@ -0,0 +1,85 @@ +export const riskyQuotaLedger = { + accountId: "LAB-QUOTA-1042", + billingCycle: "2026-06", + plan: { + name: "Lab Pro", + includedUnits: 10000, + rolloverCapUnits: 3000, + rolloverExpiresAfterCycles: 2, + downgradeCarryLimitUnits: 1000, + }, + priorCycle: { + cycle: "2026-05", + includedUnits: 10000, + usedUnits: 6100, + carryForwardUnits: 3900, + postedRolloverUnits: 3900, + }, + currentCycle: { + includedUnits: 6000, + planChangedFrom: "Lab Pro", + planChangedTo: "Individual Pro", + usageUnits: 11200, + invoiceOverageUnits: 5200, + availableRolloverUnits: 3900, + rolloverAppliedUnits: 3900, + }, + rolloverLots: [ + { lotId: "may-unused", units: 2500, originatedCycle: "2026-05", expiresAfterCycle: "2026-07", appliedUnits: 2500 }, + { lotId: "legacy-feb", units: 900, originatedCycle: "2026-02", expiresAfterCycle: "2026-04", appliedUnits: 900 }, + { lotId: "duplicate-may", units: 500, originatedCycle: "2026-05", expiresAfterCycle: "2026-07", appliedUnits: 500 }, + { lotId: "duplicate-may", units: 500, originatedCycle: "2026-05", expiresAfterCycle: "2026-07", appliedUnits: 0 }, + ], + adjustments: [ + { adjustmentId: "manual-negative-1", units: -700, approvedByFinance: false, reason: "support request" }, + ], + invoicePreview: { + lineItems: [ + { code: "base-plan", units: 6000, amountUsd: 499 }, + { code: "ai-compute-overage", units: 5200, amountUsd: 416 }, + { code: "rollover-credit", units: -3900, amountUsd: -312 }, + ], + reviewerHold: false, + }, +}; + +export const cleanQuotaLedger = { + accountId: "LAB-QUOTA-2048", + billingCycle: "2026-06", + plan: { + name: "Institutional", + includedUnits: 50000, + rolloverCapUnits: 12000, + rolloverExpiresAfterCycles: 2, + downgradeCarryLimitUnits: 4000, + }, + priorCycle: { + cycle: "2026-05", + includedUnits: 50000, + usedUnits: 42000, + carryForwardUnits: 8000, + postedRolloverUnits: 8000, + }, + currentCycle: { + includedUnits: 50000, + planChangedFrom: "Institutional", + planChangedTo: "Institutional", + usageUnits: 54800, + invoiceOverageUnits: 0, + availableRolloverUnits: 8000, + rolloverAppliedUnits: 4800, + }, + rolloverLots: [ + { lotId: "may-unused", units: 8000, originatedCycle: "2026-05", expiresAfterCycle: "2026-07", appliedUnits: 4800 }, + ], + adjustments: [ + { adjustmentId: "finance-credit-1", units: -300, approvedByFinance: true, reason: "documented SLA credit" }, + ], + invoicePreview: { + lineItems: [ + { code: "base-plan", units: 50000, amountUsd: 2499 }, + { code: "rollover-credit", units: -4800, amountUsd: 0 }, + ], + reviewerHold: true, + }, +}; diff --git a/subscription-quota-rollover-guard/test.js b/subscription-quota-rollover-guard/test.js new file mode 100644 index 00000000..2353889e --- /dev/null +++ b/subscription-quota-rollover-guard/test.js @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { auditQuotaRollover, buildFinanceMarkdown, buildSummarySvg } from "./index.js"; +import { cleanQuotaLedger, riskyQuotaLedger } from "./sample-data.js"; + +const risky = auditQuotaRollover(riskyQuotaLedger); +assert.equal(risky.decision, "hold-invoice"); +assert.ok(risky.riskScore >= 90); +assert.ok(risky.findings.some((finding) => finding.code === "ROLLOVER_CAP_EXCEEDED")); +assert.ok(risky.findings.some((finding) => finding.code === "EXPIRED_ROLLOVER_APPLIED")); +assert.ok(risky.findings.some((finding) => finding.code === "DUPLICATE_ROLLOVER_LOT")); +assert.ok(risky.findings.some((finding) => finding.code === "DOWNGRADE_CARRY_LIMIT_EXCEEDED")); +assert.ok(risky.findings.some((finding) => finding.code === "OVERAGE_DOUBLE_BILLING_RISK")); +assert.ok(risky.findings.some((finding) => finding.code === "MISSING_FINANCE_HOLD")); + +const clean = auditQuotaRollover(cleanQuotaLedger); +assert.equal(clean.decision, "ready-to-invoice"); +assert.equal(clean.riskScore, 0); +assert.equal(clean.summary.findingCount, 0); + +assert.throws(() => auditQuotaRollover(undefined), /quota ledger must be an object/); + +const markdown = buildFinanceMarkdown(risky); +assert.match(markdown, /hold-invoice/); +assert.match(markdown, /ROLLOVER_CAP_EXCEEDED/); + +const svg = buildSummarySvg(risky); +assert.match(svg, /