diff --git a/revenue-dispute-guard/README.md b/revenue-dispute-guard/README.md new file mode 100644 index 0000000..929d403 --- /dev/null +++ b/revenue-dispute-guard/README.md @@ -0,0 +1,43 @@ +# Revenue Dispute Guard + +Self-contained milestone for SCIBASE.AI issue #20, Revenue Infrastructure. + +This module evaluates payment disputes, failed payment recovery, AI compute +overages, top-up velocity, and licensing export readiness before revenue is +recognized or entitlements are extended. It is synthetic-data-only and has no +network calls, credentials, or payment-provider dependencies. + +## What It Covers + +- Chargeback and open-dispute risk scoring for subscription accounts. +- AI compute overage and rapid top-up abuse detection. +- Entitlement hold decisions for past-due or disputed accounts. +- Invoice reserve recommendations and revenue adjustment packets. +- Dunning, payment-method, procurement, and account-review actions. +- Privacy-safe licensing export gates with consent and aggregation checks. +- Stable audit digests for finance review packets. + +## Files + +- `index.js` - revenue dispute and adjustment guard engine. +- `demo.js` - terminal demo for finance guard packets. +- `test.js` - dependency-free regression tests. +- `demo.mp4` - short demo artifact for bounty review. + +## Run + +```sh +node revenue-dispute-guard/test.js +node revenue-dispute-guard/demo.js +``` + +## Requirement Map + +| Issue #20 Requirement | Implementation | +| --- | --- | +| Tiered subscription billing | Account packets include plan, recurring revenue, invoice status, entitlement decisions, and reserve actions. | +| Usage-based AI compute billing | Compute spend, budget, and top-up velocity feed risk scores and usage hold recommendations. | +| Payment integrations and invoicing | Payment failures, chargebacks, dunning actions, and institutional invoice reserves are modeled without live provider calls. | +| Data licensing APIs and analytics | Licensing exports are gated by anonymization, consent coverage, and aggregation thresholds. | +| Predictable recurring revenue | Portfolio packets surface held ARR/MRR, reserve totals, risk tiers, and audit digests for finance review. | +| Compliance and auditability | Every account packet includes evidence factors, recovery actions, revenue adjustments, and stable audit digests. | diff --git a/revenue-dispute-guard/demo.js b/revenue-dispute-guard/demo.js new file mode 100644 index 0000000..3bf81f3 --- /dev/null +++ b/revenue-dispute-guard/demo.js @@ -0,0 +1,60 @@ +const { buildPortfolioGuard } = require("./index") + +const accounts = [ + { + id: "lab-healthy", + plan: "lab", + monthlyRecurringRevenueUsd: 1200, + computeBudgetUsd: 800, + computeSpendUsd: 620, + licensingExports: [ + { + id: "reuse-trends-q2", + valueUsd: 2200, + anonymized: true, + consentCoverage: 0.99, + aggregationThreshold: 40, + }, + ], + }, + { + id: "institute-disputed", + plan: "institution", + monthlyRecurringRevenueUsd: 9000, + openDisputes: 2, + chargebacksLostLast180d: 2, + averageDisputeValueUsd: 4200, + openInvoiceBalanceUsd: 18000, + invoiceDaysPastDue: 45, + paymentFailuresLast30d: 4, + computeBudgetUsd: 12000, + computeSpendUsd: 24000, + topupPurchasesLast24h: 4, + topupValueLast24hUsd: 6000, + licensingExports: [ + { + id: "national-reuse-api", + valueUsd: 12500, + anonymized: true, + consentCoverage: 0.91, + aggregationThreshold: 8, + }, + ], + }, + { + id: "startup-topups", + plan: "pro", + monthlyRecurringRevenueUsd: 300, + paymentFailuresLast30d: 1, + computeBudgetUsd: 400, + computeSpendUsd: 980, + topupPurchasesLast24h: 5, + topupValueLast24hUsd: 700, + licensingExports: [], + }, +] + +const packet = buildPortfolioGuard(accounts, { generatedFor: "monthly_close_packet" }) + +console.log("Revenue dispute guard") +console.log(JSON.stringify(packet, null, 2)) diff --git a/revenue-dispute-guard/demo.mp4 b/revenue-dispute-guard/demo.mp4 new file mode 100644 index 0000000..0d15a89 Binary files /dev/null and b/revenue-dispute-guard/demo.mp4 differ diff --git a/revenue-dispute-guard/demo.svg b/revenue-dispute-guard/demo.svg new file mode 100644 index 0000000..1217f44 --- /dev/null +++ b/revenue-dispute-guard/demo.svg @@ -0,0 +1,22 @@ + + + + Revenue Dispute Guard + Payment disputes, failed recovery, AI compute overages, and licensing export holds + + Critical Hold + 2 open disputes + 41 days past due + $17k compute spend + + Reserve Packet + Dispute evidence + Deferred revenue + License export hold + + Audit Digest + Risk factors + Recovery actions + Stable close packet + Result: hold risky entitlements, reserve exposed revenue, and keep privacy-risky licensing exports out of recognized revenue. + diff --git a/revenue-dispute-guard/index.js b/revenue-dispute-guard/index.js new file mode 100644 index 0000000..6e3788c --- /dev/null +++ b/revenue-dispute-guard/index.js @@ -0,0 +1,249 @@ +const crypto = require("node:crypto") + +const RISK_THRESHOLDS = [ + { tier: "critical", minScore: 0.8 }, + { tier: "high", minScore: 0.6 }, + { tier: "medium", minScore: 0.35 }, + { tier: "low", minScore: 0 }, +] + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex") +} + +function clamp(value, min = 0, max = 1) { + return Math.min(Math.max(value, min), max) +} + +function roundCurrency(value) { + return Number(value.toFixed(2)) +} + +function sum(values) { + return values.reduce((total, value) => total + value, 0) +} + +function riskTier(score) { + return RISK_THRESHOLDS.find((threshold) => score >= threshold.minScore).tier +} + +function accountRiskFactors(account) { + const computeBudget = account.computeBudgetUsd || 0 + const computeSpend = account.computeSpendUsd || 0 + const computeOverageRatio = computeBudget > 0 ? Math.max(computeSpend - computeBudget, 0) / computeBudget : 0 + const topupVelocityRatio = + (account.monthlyRecurringRevenueUsd || 0) > 0 + ? (account.topupValueLast24hUsd || 0) / account.monthlyRecurringRevenueUsd + : 0 + + return [ + { + id: "lost_chargebacks", + label: "Lost chargebacks in the last 180 days", + value: account.chargebacksLostLast180d || 0, + score: clamp((account.chargebacksLostLast180d || 0) / 3), + weight: 0.2, + }, + { + id: "open_disputes", + label: "Open disputes", + value: account.openDisputes || 0, + score: clamp((account.openDisputes || 0) / 2), + weight: 0.18, + }, + { + id: "payment_failures", + label: "Failed payments in the last 30 days", + value: account.paymentFailuresLast30d || 0, + score: clamp((account.paymentFailuresLast30d || 0) / 4), + weight: 0.16, + }, + { + id: "past_due", + label: "Invoice days past due", + value: account.invoiceDaysPastDue || 0, + score: clamp((account.invoiceDaysPastDue || 0) / 45), + weight: 0.14, + }, + { + id: "compute_overage", + label: "AI compute overage ratio", + value: Number(computeOverageRatio.toFixed(3)), + score: clamp(computeOverageRatio / 0.75), + weight: 0.14, + }, + { + id: "topup_velocity", + label: "Top-up value in last 24h vs MRR", + value: Number(topupVelocityRatio.toFixed(3)), + score: clamp(topupVelocityRatio / 0.5), + weight: 0.1, + }, + { + id: "licensing_export_risk", + label: "Highest licensing export privacy risk", + value: Math.max(...(account.licensingExports || []).map(licensingExportRiskScore), 0), + score: Math.max(...(account.licensingExports || []).map(licensingExportRiskScore), 0), + weight: 0.08, + }, + ] +} + +function scoreAccountRisk(account) { + const factors = accountRiskFactors(account) + return Number(sum(factors.map((factor) => factor.score * factor.weight)).toFixed(4)) +} + +function licensingExportRiskScore(exportItem) { + const consentRisk = clamp((0.95 - (exportItem.consentCoverage || 0)) / 0.95) + const aggregationRisk = clamp((10 - (exportItem.aggregationThreshold || 0)) / 10) + const anonymizationRisk = exportItem.anonymized ? 0 : 1 + return Number((consentRisk * 0.35 + aggregationRisk * 0.3 + anonymizationRisk * 0.35).toFixed(4)) +} + +function reviewLicensingExports(account) { + return (account.licensingExports || []).map((exportItem) => { + const blockers = [] + if (!exportItem.anonymized) blockers.push("not_anonymized") + if ((exportItem.consentCoverage || 0) < 0.95) blockers.push("consent_below_threshold") + if ((exportItem.aggregationThreshold || 0) < 10) blockers.push("aggregation_threshold_too_low") + + return { + id: exportItem.id, + valueUsd: exportItem.valueUsd || 0, + decision: blockers.length > 0 ? "hold" : "approve", + blockers, + riskScore: licensingExportRiskScore(exportItem), + } + }) +} + +function entitlementDecision(account, score) { + if ((account.openDisputes || 0) > 0 && score >= 0.6) return "hold_paid_entitlements" + if ((account.invoiceDaysPastDue || 0) >= 30) return "hold_new_usage" + if ((account.computeSpendUsd || 0) > (account.computeBudgetUsd || 0) * 1.5) return "review_compute_usage" + if ((account.paymentFailuresLast30d || 0) >= 2) return "require_payment_method_update" + return "allow" +} + +function reserveRecommendation(account, licensingReviews) { + const disputedExposure = (account.openDisputes || 0) * (account.averageDisputeValueUsd || 0) + const pastDueReserve = + (account.invoiceDaysPastDue || 0) > 0 ? (account.openInvoiceBalanceUsd || 0) * 0.5 : 0 + const chargebackReserve = (account.chargebacksLostLast180d || 0) * (account.averageDisputeValueUsd || 0) + const licensingReserve = sum( + licensingReviews + .filter((review) => review.decision === "hold") + .map((review) => review.valueUsd * Math.max(review.riskScore, 0.25)), + ) + + return roundCurrency(disputedExposure + pastDueReserve + chargebackReserve + licensingReserve) +} + +function recoveryActions(account, licensingReviews, score) { + const actions = [] + if ((account.paymentFailuresLast30d || 0) > 0) actions.push("Send payment-method refresh notice.") + if ((account.invoiceDaysPastDue || 0) >= 15) actions.push("Start institutional dunning sequence.") + if ((account.openDisputes || 0) > 0) actions.push("Prepare dispute evidence packet before recognizing revenue.") + if ((account.computeSpendUsd || 0) > (account.computeBudgetUsd || 0)) actions.push("Throttle AI compute overages until top-up clears.") + if ((account.topupPurchasesLast24h || 0) >= 3) actions.push("Review rapid top-up pattern for abuse or card testing.") + if (licensingReviews.some((review) => review.decision === "hold")) { + actions.push("Hold analytics licensing export until privacy blockers clear.") + } + if (score >= 0.8) actions.push("Escalate account to finance risk review.") + return actions +} + +function revenueAdjustments(account, reserveUsd, licensingReviews) { + const adjustments = [] + if (reserveUsd > 0) { + adjustments.push({ + type: "reserve", + amountUsd: reserveUsd, + reason: "Dispute, past-due, chargeback, or licensing hold exposure.", + }) + } + if ((account.invoiceDaysPastDue || 0) >= 30) { + adjustments.push({ + type: "defer_revenue", + amountUsd: roundCurrency((account.openInvoiceBalanceUsd || 0) * 0.5), + reason: "Invoice is 30+ days past due.", + }) + } + for (const review of licensingReviews.filter((item) => item.decision === "hold")) { + adjustments.push({ + type: "hold_license_revenue", + amountUsd: review.valueUsd, + reason: `Licensing export ${review.id} has privacy blockers.`, + }) + } + return adjustments +} + +function buildAccountGuard(account) { + const score = scoreAccountRisk(account) + const licensingReviews = reviewLicensingExports(account) + const reserveUsd = reserveRecommendation(account, licensingReviews) + const packet = { + accountId: account.id, + plan: account.plan, + monthlyRecurringRevenueUsd: account.monthlyRecurringRevenueUsd || 0, + riskScore: score, + riskTier: riskTier(score), + entitlementDecision: entitlementDecision(account, score), + riskFactors: accountRiskFactors(account).map((factor) => ({ + id: factor.id, + label: factor.label, + value: factor.value, + score: factor.score, + weight: factor.weight, + })), + licensingReviews, + reserveUsd, + revenueAdjustments: revenueAdjustments(account, reserveUsd, licensingReviews), + recoveryActions: recoveryActions(account, licensingReviews, score), + } + + return { + ...packet, + auditDigest: digest(packet), + } +} + +function buildPortfolioGuard(accounts, options = {}) { + const accountPackets = accounts.map(buildAccountGuard).sort((a, b) => b.riskScore - a.riskScore) + const packet = { + generatedFor: options.generatedFor || "finance_review", + accountCount: accountPackets.length, + heldAccounts: accountPackets.filter((account) => account.entitlementDecision !== "allow").length, + totalMonthlyRecurringRevenueUsd: roundCurrency(sum(accountPackets.map((account) => account.monthlyRecurringRevenueUsd))), + totalReserveUsd: roundCurrency(sum(accountPackets.map((account) => account.reserveUsd))), + criticalAccounts: accountPackets.filter((account) => account.riskTier === "critical").map((account) => account.accountId), + accounts: accountPackets, + } + + return { + ...packet, + auditDigest: digest(accountPackets.map((account) => account.auditDigest)), + } +} + +module.exports = { + buildAccountGuard, + buildPortfolioGuard, + digest, + licensingExportRiskScore, + scoreAccountRisk, + stableJson, +} diff --git a/revenue-dispute-guard/test.js b/revenue-dispute-guard/test.js new file mode 100644 index 0000000..e4cec42 --- /dev/null +++ b/revenue-dispute-guard/test.js @@ -0,0 +1,131 @@ +const assert = require("node:assert/strict") +const { + buildAccountGuard, + buildPortfolioGuard, + digest, + licensingExportRiskScore, + scoreAccountRisk, +} = require("./index") + +function healthyAccount() { + return { + id: "healthy", + plan: "lab", + monthlyRecurringRevenueUsd: 1000, + computeBudgetUsd: 800, + computeSpendUsd: 500, + licensingExports: [ + { + id: "safe-export", + valueUsd: 1200, + anonymized: true, + consentCoverage: 0.99, + aggregationThreshold: 30, + }, + ], + } +} + +function disputedAccount() { + return { + id: "disputed", + plan: "institution", + monthlyRecurringRevenueUsd: 5000, + openDisputes: 2, + chargebacksLostLast180d: 2, + averageDisputeValueUsd: 2500, + paymentFailuresLast30d: 4, + invoiceDaysPastDue: 45, + openInvoiceBalanceUsd: 10000, + computeBudgetUsd: 2000, + computeSpendUsd: 4100, + topupPurchasesLast24h: 4, + topupValueLast24hUsd: 4000, + licensingExports: [ + { + id: "unsafe-export", + valueUsd: 3000, + anonymized: false, + consentCoverage: 0.8, + aggregationThreshold: 4, + }, + ], + } +} + +function testLowRiskAccountAllowsEntitlements() { + const guard = buildAccountGuard(healthyAccount()) + + assert.equal(guard.riskTier, "low") + assert.equal(guard.entitlementDecision, "allow") + assert.equal(guard.reserveUsd, 0) + assert.equal(guard.licensingReviews[0].decision, "approve") +} + +function testDisputedAccountIsHeldAndReserved() { + const guard = buildAccountGuard(disputedAccount()) + + assert.equal(guard.riskTier, "critical") + assert.equal(guard.entitlementDecision, "hold_paid_entitlements") + assert.ok(guard.reserveUsd > 12000) + assert.ok(guard.recoveryActions.some((action) => action.includes("dispute evidence"))) +} + +function testTopupVelocityAddsAbuseReviewAction() { + const guard = buildAccountGuard({ + ...healthyAccount(), + id: "rapid-topup", + monthlyRecurringRevenueUsd: 500, + topupPurchasesLast24h: 5, + topupValueLast24hUsd: 1000, + }) + + assert.ok(guard.riskFactors.find((factor) => factor.id === "topup_velocity").score > 0) + assert.ok(guard.recoveryActions.some((action) => action.includes("rapid top-up"))) +} + +function testLicensingExportRiskBlocksUnsafeExports() { + const risk = licensingExportRiskScore({ + anonymized: false, + consentCoverage: 0.8, + aggregationThreshold: 3, + }) + const guard = buildAccountGuard(disputedAccount()) + + assert.ok(risk > 0.6) + assert.equal(guard.licensingReviews[0].decision, "hold") + assert.ok(guard.licensingReviews[0].blockers.includes("not_anonymized")) +} + +function testPortfolioTotalsAndSorting() { + const portfolio = buildPortfolioGuard([healthyAccount(), disputedAccount()]) + + assert.equal(portfolio.accountCount, 2) + assert.equal(portfolio.heldAccounts, 1) + assert.equal(portfolio.accounts[0].accountId, "disputed") + assert.ok(portfolio.totalReserveUsd > 12000) +} + +function testRiskAndDigestAreDeterministic() { + const first = scoreAccountRisk(disputedAccount()) + const second = scoreAccountRisk({ ...disputedAccount() }) + + assert.equal(first, second) + assert.equal(digest({ b: 2, a: 1 }), digest({ a: 1, b: 2 })) +} + +const tests = [ + testLowRiskAccountAllowsEntitlements, + testDisputedAccountIsHeldAndReserved, + testTopupVelocityAddsAbuseReviewAction, + testLicensingExportRiskBlocksUnsafeExports, + testPortfolioTotalsAndSorting, + testRiskAndDigestAreDeterministic, +] + +for (const test of tests) { + test() + console.log(`ok - ${test.name}`) +} + +console.log(`${tests.length} tests passed`)