diff --git a/README.md b/README.md index d338cf6..8336108 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Revenue infrastructure additions + +- `revenue-dispute-evidence-guard`: chargeback and failed-payment evidence guard for revenue recovery, entitlement holds, and payment-rail dispute packets. diff --git a/revenue-dispute-evidence-guard/README.md b/revenue-dispute-evidence-guard/README.md new file mode 100644 index 0000000..7a14ee4 --- /dev/null +++ b/revenue-dispute-evidence-guard/README.md @@ -0,0 +1,25 @@ +# Revenue Dispute Evidence Guard + +Revenue infrastructure needs controls for the moments after billing succeeds or fails: card disputes, invoice short-payments, failed top-ups, and license access that should pause until finance has enough evidence to recover revenue. This module adds a deterministic chargeback and payment-dispute evidence guard for institutional revenue operations. + +The guard is self-contained and credential-free. It consumes synthetic customer, invoice, payment, entitlement, and usage records, then emits: + +- dispute and failed-payment findings with severity and recovery deadlines +- entitlement hold/release decisions for subscriptions, compute top-ups, and data licenses +- payment-rail evidence packets for Stripe, PayPal, and institutional invoice workflows +- finance actions prioritized by revenue at risk and due date +- deterministic audit digests for reviewer-ready validation + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +The demo reads `data/sample-revenue-input.json`. Visual review artifacts are in `docs/demo.svg` and `docs/demo.gif`. + +## Fit For Issue #20 + +This targets the Revenue Infrastructure requirements for secure payment integrations, institutional invoicing, subscription billing, AI compute usage, top-ups, and licensing API revenue. It is distinct from prior billing, metering, entitlement, procurement, licensing, tax, margin, renewal, and revenue-recognition slices because it focuses on payment reversals, evidence readiness, dispute deadlines, and entitlement holds after payment risk appears. diff --git a/revenue-dispute-evidence-guard/data/sample-revenue-input.json b/revenue-dispute-evidence-guard/data/sample-revenue-input.json new file mode 100644 index 0000000..cb30308 --- /dev/null +++ b/revenue-dispute-evidence-guard/data/sample-revenue-input.json @@ -0,0 +1,140 @@ +{ + "generatedAt": "2026-05-16T14:45:00Z", + "organization": "Northbridge Research Cloud", + "customers": [ + { + "id": "cust-lab-a", + "name": "Atlas Organoid Lab", + "tier": "lab", + "accountOwner": "Maya Patel", + "billingEmail": "billing+atlas@example.edu" + }, + { + "id": "cust-institute-b", + "name": "Helix Institute Consortium", + "tier": "institution", + "accountOwner": "Evan Brooks", + "billingEmail": "ap+helix@example.org" + }, + { + "id": "cust-analyst-c", + "name": "Quanta Policy Analytics", + "tier": "licensing", + "accountOwner": "Lena Ross", + "billingEmail": "finance+quanta@example.net" + } + ], + "invoices": [ + { + "id": "inv-1001", + "customerId": "cust-lab-a", + "amountUsd": 2400, + "issuedAt": "2026-04-28", + "dueAt": "2026-05-12", + "status": "paid", + "lineItems": ["lab_subscription", "ai_compute_topup"] + }, + { + "id": "inv-1002", + "customerId": "cust-institute-b", + "amountUsd": 9800, + "issuedAt": "2026-04-20", + "dueAt": "2026-05-10", + "status": "short_paid", + "lineItems": ["institution_license", "priority_support"] + }, + { + "id": "inv-1003", + "customerId": "cust-analyst-c", + "amountUsd": 5200, + "issuedAt": "2026-05-01", + "dueAt": "2026-05-15", + "status": "open", + "lineItems": ["analytics_api_license"] + } + ], + "payments": [ + { + "id": "pay-778", + "invoiceId": "inv-1001", + "rail": "stripe", + "amountUsd": 2400, + "status": "disputed", + "disputeReason": "fraudulent", + "disputeDueAt": "2026-05-18", + "evidence": ["signed_order_form", "seat_login_history", "compute_usage_log", "receipt_email"], + "requiredEvidence": ["signed_order_form", "service_acceptance", "seat_login_history", "compute_usage_log", "receipt_email"] + }, + { + "id": "pay-812", + "invoiceId": "inv-1002", + "rail": "institutional_invoice", + "amountUsd": 7600, + "status": "short_paid", + "disputeReason": "missing_purchase_order", + "disputeDueAt": "2026-05-17", + "evidence": ["signed_order_form", "usage_report"], + "requiredEvidence": ["signed_order_form", "purchase_order", "usage_report", "invoice_pdf"] + }, + { + "id": "pay-845", + "invoiceId": "inv-1003", + "rail": "paypal", + "amountUsd": 0, + "status": "failed", + "disputeReason": "funding_source_declined", + "disputeDueAt": "2026-05-20", + "evidence": ["invoice_pdf"], + "requiredEvidence": ["invoice_pdf", "license_delivery_log", "api_access_log"] + } + ], + "entitlements": [ + { + "id": "ent-lab-a-compute", + "customerId": "cust-lab-a", + "invoiceId": "inv-1001", + "kind": "ai_compute_topup", + "monthlyValueUsd": 900, + "status": "active" + }, + { + "id": "ent-institute-b-license", + "customerId": "cust-institute-b", + "invoiceId": "inv-1002", + "kind": "institution_license", + "monthlyValueUsd": 6200, + "status": "active" + }, + { + "id": "ent-analyst-c-api", + "customerId": "cust-analyst-c", + "invoiceId": "inv-1003", + "kind": "analytics_api_license", + "monthlyValueUsd": 5200, + "status": "provisioning" + } + ], + "usageEvents": [ + { + "customerId": "cust-lab-a", + "invoiceId": "inv-1001", + "kind": "ai_inference_hours", + "quantity": 330, + "recordedAt": "2026-05-11" + }, + { + "customerId": "cust-institute-b", + "invoiceId": "inv-1002", + "kind": "institution_active_seats", + "quantity": 180, + "recordedAt": "2026-05-12" + }, + { + "customerId": "cust-analyst-c", + "invoiceId": "inv-1003", + "kind": "analytics_api_queries", + "quantity": 24000, + "recordedAt": "2026-05-13" + } + ] +} diff --git a/revenue-dispute-evidence-guard/docs/demo.gif b/revenue-dispute-evidence-guard/docs/demo.gif new file mode 100644 index 0000000..42ba32d Binary files /dev/null and b/revenue-dispute-evidence-guard/docs/demo.gif differ diff --git a/revenue-dispute-evidence-guard/docs/demo.mp4 b/revenue-dispute-evidence-guard/docs/demo.mp4 new file mode 100644 index 0000000..1f09d81 Binary files /dev/null and b/revenue-dispute-evidence-guard/docs/demo.mp4 differ diff --git a/revenue-dispute-evidence-guard/docs/demo.svg b/revenue-dispute-evidence-guard/docs/demo.svg new file mode 100644 index 0000000..4b96ebe --- /dev/null +++ b/revenue-dispute-evidence-guard/docs/demo.svg @@ -0,0 +1,34 @@ + + Revenue dispute evidence guard demo + Dashboard preview showing revenue at risk, evidence packets, entitlement holds, and finance actions. + + + SCIBASE Revenue Dispute Evidence Guard + Northbridge Research Cloud + Payment risk becomes recovery evidence + Chargebacks, short-payments, and failed funding pause entitlements until evidence packets are ready. + + + Revenue at risk + $17.4k + + Findings + 3 + + Held entitlements + 1 + + Limited entitlements + 2 + + + Packet readiness + + Stripe dispute missing service acceptance + + Institutional invoice missing PO and PDF + + Finance actions + assemble_dispute_packet + pause_provisioning_and_retry_payment + diff --git a/revenue-dispute-evidence-guard/docs/requirement-map.md b/revenue-dispute-evidence-guard/docs/requirement-map.md new file mode 100644 index 0000000..92ce4e4 --- /dev/null +++ b/revenue-dispute-evidence-guard/docs/requirement-map.md @@ -0,0 +1,14 @@ +# Requirement Map + +| Issue #20 capability | Implementation evidence | +| --- | --- | +| Secure payment integrations | Stripe, PayPal, and institutional invoice rails are modeled in `data/sample-revenue-input.json` and packetized by `evidencePackets()` | +| Subscription billing | customer tiers, invoice line items, and active entitlements are connected to payment risk | +| AI compute billing and top-ups | `ai_compute_topup` entitlement and compute usage events are included in entitlement hold decisions | +| Institutional invoicing | short-paid institutional invoice flow requires purchase order and invoice PDF evidence | +| Licensing APIs and analytics | analytics API license entitlement is paused when PayPal funding fails | +| Revenue sustainability | `revenueAtRiskUsd`, finance actions, due dates, and deterministic digests prioritize recovery work | + +## Distinctness + +This module is not another billing ledger, metering engine, entitlement calculator, procurement control, privacy-safe licensing gate, tax exemption checker, margin guard, renewal true-up, or revenue-recognition close. It focuses on post-billing payment risk: disputes, short-payments, failed payments, evidence packets, and entitlement holds. diff --git a/revenue-dispute-evidence-guard/package.json b/revenue-dispute-evidence-guard/package.json new file mode 100644 index 0000000..5d42bbf --- /dev/null +++ b/revenue-dispute-evidence-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "revenue-dispute-evidence-guard", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "check": "node --check src/dispute-evidence-guard.js && node --check scripts/demo.js && node --check test/dispute-evidence-guard.test.js", + "test": "node --test test/dispute-evidence-guard.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/revenue-dispute-evidence-guard/scripts/demo.js b/revenue-dispute-evidence-guard/scripts/demo.js new file mode 100644 index 0000000..9e14d62 --- /dev/null +++ b/revenue-dispute-evidence-guard/scripts/demo.js @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { analyzeRevenueDisputes } from "../src/dispute-evidence-guard.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const input = JSON.parse(readFileSync(join(root, "data", "sample-revenue-input.json"), "utf8")); +const report = analyzeRevenueDisputes(input); + +console.log(`${report.organization} dispute evidence guard`); +console.log(`Evidence digest: ${report.evidenceDigest}`); +console.log(`Revenue at risk: $${report.summary.revenueAtRiskUsd}`); +console.log(`Findings: ${report.summary.paymentsAtRisk} (${report.summary.criticalFindings} critical, ${report.summary.highFindings} high)`); +console.log(`Entitlements: ${report.summary.heldEntitlements} held, ${report.summary.limitedEntitlements} limited`); +console.log("Top finance actions:"); +for (const action of report.financeActions.slice(0, 5)) { + console.log(`- [${action.severity}] ${action.action}: ${action.paymentId} missing ${action.missingEvidence.join(", ") || "none"}`); +} diff --git a/revenue-dispute-evidence-guard/src/dispute-evidence-guard.js b/revenue-dispute-evidence-guard/src/dispute-evidence-guard.js new file mode 100644 index 0000000..6256a4e --- /dev/null +++ b/revenue-dispute-evidence-guard/src/dispute-evidence-guard.js @@ -0,0 +1,209 @@ +import { createHash } from "node:crypto"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function stableHash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function asDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error(`Invalid date: ${value}`); + return date; +} + +function daysUntil(later, now) { + return Math.ceil((asDate(later).getTime() - asDate(now).getTime()) / DAY_MS); +} + +function severityRank(severity) { + return { critical: 0, high: 1, medium: 2, low: 3 }[severity] ?? 4; +} + +function byId(items = []) { + return new Map(items.map((item) => [item.id, item])); +} + +function buildFinding(kind, severity, subject, message, details = {}) { + return { + id: stableHash({ kind, subject, message, details }), + kind, + severity, + subject, + message, + details + }; +} + +function missingEvidence(payment) { + const provided = new Set(payment.evidence ?? []); + return (payment.requiredEvidence ?? []).filter((item) => !provided.has(item)).sort(); +} + +function classifyPayment(payment, invoice, now) { + const dueInDays = daysUntil(payment.disputeDueAt, now); + const missing = missingEvidence(payment); + const revenueAtRiskUsd = invoice?.amountUsd ?? payment.amountUsd ?? 0; + + if (payment.status === "disputed") { + return buildFinding( + "payment_disputed", + missing.length > 0 || dueInDays <= 2 ? "critical" : "high", + payment.id, + `${payment.rail} payment is disputed and needs evidence before the deadline`, + { invoiceId: payment.invoiceId, revenueAtRiskUsd, dueInDays, missingEvidence: missing, disputeReason: payment.disputeReason } + ); + } + + if (payment.status === "short_paid") { + return buildFinding( + "invoice_short_paid", + missing.length > 0 || dueInDays <= 2 ? "high" : "medium", + payment.id, + `${payment.rail} payment did not cover the invoice balance`, + { invoiceId: payment.invoiceId, revenueAtRiskUsd, paidUsd: payment.amountUsd, dueInDays, missingEvidence: missing, disputeReason: payment.disputeReason } + ); + } + + if (payment.status === "failed") { + return buildFinding( + "payment_failed", + "high", + payment.id, + `${payment.rail} payment failed before entitlement activation`, + { invoiceId: payment.invoiceId, revenueAtRiskUsd, dueInDays, missingEvidence: missing, disputeReason: payment.disputeReason } + ); + } + + return null; +} + +function buildFindings(input, now) { + const invoices = byId(input.invoices); + return (input.payments ?? []) + .map((payment) => classifyPayment(payment, invoices.get(payment.invoiceId), now)) + .filter(Boolean) + .sort((a, b) => { + const bySeverity = severityRank(a.severity) - severityRank(b.severity); + if (bySeverity !== 0) return bySeverity; + return a.subject.localeCompare(b.subject); + }); +} + +function entitlementDecisions(input, findings) { + const findingsByInvoice = new Map(); + for (const finding of findings) { + const invoiceId = finding.details.invoiceId; + if (!findingsByInvoice.has(invoiceId)) findingsByInvoice.set(invoiceId, []); + findingsByInvoice.get(invoiceId).push(finding); + } + + return (input.entitlements ?? []).map((entitlement) => { + const relatedFindings = findingsByInvoice.get(entitlement.invoiceId) ?? []; + const hasCritical = relatedFindings.some((finding) => finding.severity === "critical"); + const hasHigh = relatedFindings.some((finding) => finding.severity === "high"); + const decision = hasCritical ? "hold_access" : hasHigh ? "limit_until_resolved" : "release"; + + return { + entitlementId: entitlement.id, + customerId: entitlement.customerId, + kind: entitlement.kind, + monthlyValueUsd: entitlement.monthlyValueUsd, + decision, + findingIds: relatedFindings.map((finding) => finding.id).sort() + }; + }); +} + +function evidencePackets(input, findings) { + const invoices = byId(input.invoices); + const usageByInvoice = new Map(); + for (const event of input.usageEvents ?? []) { + if (!usageByInvoice.has(event.invoiceId)) usageByInvoice.set(event.invoiceId, []); + usageByInvoice.get(event.invoiceId).push(event); + } + + return (input.payments ?? []) + .filter((payment) => payment.status !== "succeeded") + .map((payment) => { + const invoice = invoices.get(payment.invoiceId); + const relatedFindings = findings.filter((finding) => finding.details.invoiceId === payment.invoiceId); + const missing = missingEvidence(payment); + + return { + packetId: stableHash({ paymentId: payment.id, invoiceId: payment.invoiceId, evidence: payment.evidence, missing }), + paymentId: payment.id, + invoiceId: payment.invoiceId, + rail: payment.rail, + dueAt: payment.disputeDueAt, + revenueAtRiskUsd: invoice?.amountUsd ?? payment.amountUsd, + readyForSubmission: missing.length === 0, + providedEvidence: [...(payment.evidence ?? [])].sort(), + missingEvidence: missing, + usageEventCount: usageByInvoice.get(payment.invoiceId)?.length ?? 0, + findingIds: relatedFindings.map((finding) => finding.id).sort() + }; + }) + .sort((a, b) => asDate(a.dueAt) - asDate(b.dueAt)); +} + +function financeActions(findings, packets) { + const packetByPayment = byId(packets.map((packet) => ({ ...packet, id: packet.paymentId }))); + + return findings.map((finding) => { + const packet = packetByPayment.get(finding.subject); + const missing = packet?.missingEvidence ?? []; + const action = finding.kind === "payment_disputed" + ? "assemble_dispute_packet" + : finding.kind === "invoice_short_paid" + ? "request_purchase_order_or_balance" + : "pause_provisioning_and_retry_payment"; + + return { + action, + severity: finding.severity, + paymentId: finding.subject, + invoiceId: finding.details.invoiceId, + dueInDays: finding.details.dueInDays, + revenueAtRiskUsd: finding.details.revenueAtRiskUsd, + missingEvidence: missing, + packetId: packet?.packetId + }; + }).sort((a, b) => { + const bySeverity = severityRank(a.severity) - severityRank(b.severity); + if (bySeverity !== 0) return bySeverity; + const byDueDate = a.dueInDays - b.dueInDays; + if (byDueDate !== 0) return byDueDate; + return b.revenueAtRiskUsd - a.revenueAtRiskUsd; + }); +} + +export function analyzeRevenueDisputes(input, options = {}) { + const now = options.now ?? input.generatedAt ?? new Date().toISOString(); + const findings = buildFindings(input, now); + const entitlements = entitlementDecisions(input, findings); + const packets = evidencePackets(input, findings); + const actions = financeActions(findings, packets); + const revenueAtRiskUsd = findings.reduce((sum, finding) => sum + finding.details.revenueAtRiskUsd, 0); + + return { + organization: input.organization, + generatedAt: now, + summary: { + customers: input.customers?.length ?? 0, + invoices: input.invoices?.length ?? 0, + paymentsAtRisk: findings.length, + revenueAtRiskUsd, + criticalFindings: findings.filter((finding) => finding.severity === "critical").length, + highFindings: findings.filter((finding) => finding.severity === "high").length, + heldEntitlements: entitlements.filter((entitlement) => entitlement.decision === "hold_access").length, + limitedEntitlements: entitlements.filter((entitlement) => entitlement.decision === "limit_until_resolved").length, + readyEvidencePackets: packets.filter((packet) => packet.readyForSubmission).length + }, + findings, + entitlementDecisions: entitlements, + evidencePackets: packets, + financeActions: actions, + evidenceDigest: stableHash({ now, findings, entitlements, packets }) + }; +} diff --git a/revenue-dispute-evidence-guard/test/dispute-evidence-guard.test.js b/revenue-dispute-evidence-guard/test/dispute-evidence-guard.test.js new file mode 100644 index 0000000..39a3c55 --- /dev/null +++ b/revenue-dispute-evidence-guard/test/dispute-evidence-guard.test.js @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { analyzeRevenueDisputes } from "../src/dispute-evidence-guard.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-revenue-input.json"), "utf8")); + +describe("analyzeRevenueDisputes", () => { + it("detects disputed, short-paid, and failed payments", () => { + const report = analyzeRevenueDisputes(sample); + const kinds = new Set(report.findings.map((finding) => finding.kind)); + + assert.equal(report.summary.paymentsAtRisk, 3); + assert.equal(report.summary.revenueAtRiskUsd, 17400); + assert.equal(kinds.has("payment_disputed"), true); + assert.equal(kinds.has("invoice_short_paid"), true); + assert.equal(kinds.has("payment_failed"), true); + }); + + it("holds or limits entitlements while revenue evidence is incomplete", () => { + const report = analyzeRevenueDisputes(sample); + const decisions = new Map(report.entitlementDecisions.map((entitlement) => [entitlement.entitlementId, entitlement.decision])); + + assert.equal(decisions.get("ent-lab-a-compute"), "hold_access"); + assert.equal(decisions.get("ent-institute-b-license"), "limit_until_resolved"); + assert.equal(decisions.get("ent-analyst-c-api"), "limit_until_resolved"); + }); + + it("builds evidence packets with missing evidence lists", () => { + const report = analyzeRevenueDisputes(sample); + const stripePacket = report.evidencePackets.find((packet) => packet.paymentId === "pay-778"); + const invoicePacket = report.evidencePackets.find((packet) => packet.paymentId === "pay-812"); + + assert.deepEqual(stripePacket.missingEvidence, ["service_acceptance"]); + assert.deepEqual(invoicePacket.missingEvidence, ["invoice_pdf", "purchase_order"]); + assert.equal(report.summary.readyEvidencePackets, 0); + }); + + it("is deterministic for audit evidence", () => { + const first = analyzeRevenueDisputes(sample); + const second = analyzeRevenueDisputes(sample); + + assert.equal(first.evidenceDigest, second.evidenceDigest); + assert.deepEqual(first.findings.map((finding) => finding.id), second.findings.map((finding) => finding.id)); + }); +});