From e1c32badd85b97edb52d70b94cadf1db8f8e0aab Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 21:59:27 +0700 Subject: [PATCH] Add billing customer consolidation guard --- .../README.md | 32 ++ billing-customer-consolidation-guard/demo.js | 139 +++++++ billing-customer-consolidation-guard/index.js | 338 ++++++++++++++++++ .../reports/consolidation-packet.json | 216 +++++++++++ .../reports/demo.mp4 | Bin 0 -> 8315 bytes .../reports/finance-review-packet.md | 75 ++++ .../reports/summary.svg | 23 ++ .../requirements-map.md | 30 ++ .../sample-data.js | 86 +++++ billing-customer-consolidation-guard/test.js | 127 +++++++ 10 files changed, 1066 insertions(+) create mode 100644 billing-customer-consolidation-guard/README.md create mode 100644 billing-customer-consolidation-guard/demo.js create mode 100644 billing-customer-consolidation-guard/index.js create mode 100644 billing-customer-consolidation-guard/reports/consolidation-packet.json create mode 100644 billing-customer-consolidation-guard/reports/demo.mp4 create mode 100644 billing-customer-consolidation-guard/reports/finance-review-packet.md create mode 100644 billing-customer-consolidation-guard/reports/summary.svg create mode 100644 billing-customer-consolidation-guard/requirements-map.md create mode 100644 billing-customer-consolidation-guard/sample-data.js create mode 100644 billing-customer-consolidation-guard/test.js diff --git a/billing-customer-consolidation-guard/README.md b/billing-customer-consolidation-guard/README.md new file mode 100644 index 00000000..1f067236 --- /dev/null +++ b/billing-customer-consolidation-guard/README.md @@ -0,0 +1,32 @@ +# Billing Customer Consolidation Guard + +Self-contained Revenue Infrastructure slice for issue #20. It evaluates duplicate billing/customer profiles inside one institution before subscriptions, AI compute credits, analytics seats, invoices, coupons, tax identities, and payment evidence are consolidated. + +## What It Checks + +- Legal name and tax ID conflicts before customer records merge. +- Duplicate active subscriptions for the same revenue product. +- Large or expiring AI compute credit balances that need explicit survivor-account transfer. +- Unverified payment evidence that should not unlock paid entitlements. +- Open receivables that must remain traceable after consolidation. +- Analytics dashboard and API seat totals for survivor-account true-up. + +## Outputs + +- `reports/consolidation-packet.json`: structured decisions, findings, survivor-account drafts, and finance actions. +- `reports/finance-review-packet.md`: readable finance review packet for every synthetic scenario. +- `reports/summary.svg`: visual decision summary. +- `reports/demo.mp4`: short demo artifact for Algora review. + +## Local Verification + +```bash +node --test billing-customer-consolidation-guard/test.js +node billing-customer-consolidation-guard/demo.js +node --check billing-customer-consolidation-guard/index.js +node --check billing-customer-consolidation-guard/sample-data.js +node --check billing-customer-consolidation-guard/demo.js +node --check billing-customer-consolidation-guard/test.js +``` + +The module is dependency-free, uses synthetic data only, and makes no network calls. Set `FFMPEG_PATH` to an ffmpeg binary before running `demo.js` if regenerating `reports/demo.mp4`. diff --git a/billing-customer-consolidation-guard/demo.js b/billing-customer-consolidation-guard/demo.js new file mode 100644 index 00000000..4ed65cc0 --- /dev/null +++ b/billing-customer-consolidation-guard/demo.js @@ -0,0 +1,139 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); + +const { + evaluateBillingCustomerConsolidation, + buildFinanceReviewPacket, +} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +const framesDir = path.join(reportsDir, 'frames'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateBillingCustomerConsolidation(scenario), +})); + +const decisionCounts = evaluations.reduce((counts, item) => { + counts[item.decision] = (counts[item.decision] || 0) + 1; + return counts; +}, {}); +const totalOpenReceivable = evaluations.reduce((sum, item) => sum + item.summary.openReceivableCents, 0); +const totalCreditBalance = evaluations.reduce((sum, item) => sum + item.summary.creditBalanceCents, 0); +const totalFindings = evaluations.reduce((sum, item) => sum + item.findings.length, 0); + +function money(cents) { + return `$${(cents / 100).toFixed(2)}`; +} + +const packetJson = JSON.stringify(evaluations, null, 2); +const reviewerPacket = evaluations.map(buildFinanceReviewPacket).join('\n---\n'); +const svg = ` + + Billing Customer Consolidation Guard + Revenue-control packet for duplicate customer profile merges + + + Blocked + ${decisionCounts['block-consolidation'] || 0} + + + + Finance Review + ${decisionCounts['finance-review'] || 0} + + + + Ready + ${decisionCounts['ready-to-consolidate'] || 0} + + Open receivables preserved: ${money(totalOpenReceivable)} | Credit balances preserved: ${money(totalCreditBalance)} + Checks: legal identity, duplicate subscriptions, payment evidence, credit lots, invoices, analytics seats + Synthetic data only. No customer secrets, payment processor calls, bank data, ERP, SSO, or live billing systems. + +`; + +fs.writeFileSync(path.join(reportsDir, 'consolidation-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'finance-review-packet.md'), reviewerPacket); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) { + const [r, g, b] = color; + const x1 = Math.min(width, x0 + rectWidth); + const y1 = Math.min(Math.floor(buffer.length / (width * 3)), y0 + rectHeight); + for (let y = Math.max(0, y0); y < y1; y += 1) { + for (let x = Math.max(0, x0); x < x1; x += 1) { + const offset = (y * width + x) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; + } + } +} + +function writePpmFrame(filePath, frameIndex, frameCount) { + const width = 640; + const height = 360; + const buffer = Buffer.alloc(width * height * 3); + for (let i = 0; i < width * height; i += 1) { + buffer[i * 3] = 16; + buffer[i * 3 + 1] = 32; + buffer[i * 3 + 2] = 28; + } + const progress = frameIndex / Math.max(1, frameCount - 1); + fillRect(buffer, width, 42, 42, 556, 44, [248, 250, 252]); + fillRect(buffer, width, 42, 112, 160, 118, [127, 29, 29]); + fillRect(buffer, width, 240, 112, 160, 118, [133, 77, 14]); + fillRect(buffer, width, 438, 112, 160, 118, [22, 101, 52]); + fillRect(buffer, width, 42, 266, Math.round(556 * progress), 28, [183, 247, 223]); + fillRect(buffer, width, 42 + Math.round(492 * progress), 310, 64, 26, [248, 250, 252]); + const header = Buffer.from(`P6\n${width} ${height}\n255\n`); + fs.writeFileSync(filePath, Buffer.concat([header, buffer])); +} + +function createDemoVideo() { + const ffmpegPath = process.env.FFMPEG_PATH; + if (!ffmpegPath) { + console.log('FFMPEG_PATH not set; skipped MP4 generation.'); + return; + } + + fs.rmSync(framesDir, {recursive: true, force: true}); + fs.mkdirSync(framesDir, {recursive: true}); + const frameCount = 72; + for (let index = 0; index < frameCount; index += 1) { + writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount); + } + + const output = path.join(reportsDir, 'demo.mp4'); + const result = spawnSync(ffmpegPath, [ + '-y', + '-framerate', + '24', + '-i', + path.join(framesDir, 'frame-%03d.ppm'), + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + output, + ], {encoding: 'utf8'}); + + fs.rmSync(framesDir, {recursive: true, force: true}); + if (result.status !== 0) { + throw new Error(result.stderr || 'ffmpeg failed to generate demo.mp4'); + } +} + +createDemoVideo(); + +console.log(`Wrote ${evaluations.length} consolidation evaluations to ${reportsDir}`); +console.log(`Decision counts: ${JSON.stringify(decisionCounts)}`); +console.log(`Open receivables: ${money(totalOpenReceivable)}`); +console.log(`Credit balances: ${money(totalCreditBalance)}`); +console.log(`Findings: ${totalFindings}`); diff --git a/billing-customer-consolidation-guard/index.js b/billing-customer-consolidation-guard/index.js new file mode 100644 index 00000000..cd6500bd --- /dev/null +++ b/billing-customer-consolidation-guard/index.js @@ -0,0 +1,338 @@ +function list(value) { + return Array.isArray(value) ? value : []; +} + +function cents(value) { + return Number.isFinite(value) ? value : 0; +} + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalize(value) { + return String(value || '').trim().toLowerCase(); +} + +function formatMoney(centsValue) { + return `$${(centsValue / 100).toFixed(2)}`; +} + +function fullDaysBetween(older, newer) { + const olderTime = Date.parse(older); + const newerTime = Date.parse(newer); + if (!Number.isFinite(olderTime) || !Number.isFinite(newerTime)) { + return Infinity; + } + return Math.floor((newerTime - olderTime) / 86400000); +} + +function addFinding(findings, requiredActions, finding, action) { + findings.push(finding); + if (action) { + requiredActions.push(action); + } +} + +function action(type, target, reason) { + return {type, target, reason}; +} + +function collectProfiles(input) { + return list(input.profiles).map((profile) => ({ + ...profile, + subscriptions: list(profile.subscriptions), + invoices: list(profile.invoices), + computeCreditLots: list(profile.computeCreditLots), + coupons: list(profile.coupons), + analyticsSeats: profile.analyticsSeats || {}, + })); +} + +function uniqueFilled(values) { + return [...new Set(values.map(normalize).filter(Boolean))].sort(); +} + +function activeSubscriptions(profile) { + return profile.subscriptions.filter((subscription) => normalize(subscription.status) === 'active'); +} + +function openInvoices(profile) { + return profile.invoices.filter((invoice) => cents(invoice.balanceCents) > 0 && normalize(invoice.status) !== 'void'); +} + +function creditLots(profile) { + return profile.computeCreditLots.filter((lot) => cents(lot.remainingCents) > 0); +} + +function summarize(profiles) { + const profileIds = profiles.map((profile) => profile.id || 'unknown-profile'); + const subscriptionIds = []; + const invoiceIds = []; + let openReceivableCents = 0; + let creditBalanceCents = 0; + const analyticsSeats = {dashboard: 0, api: 0}; + + for (const profile of profiles) { + for (const subscription of profile.subscriptions) { + if (hasText(subscription.id)) { + subscriptionIds.push(subscription.id); + } + } + for (const invoice of profile.invoices) { + if (hasText(invoice.id)) { + invoiceIds.push(invoice.id); + } + if (cents(invoice.balanceCents) > 0 && normalize(invoice.status) !== 'void') { + openReceivableCents += cents(invoice.balanceCents); + } + } + for (const lot of creditLots(profile)) { + creditBalanceCents += cents(lot.remainingCents); + } + analyticsSeats.dashboard += cents(profile.analyticsSeats.dashboard); + analyticsSeats.api += cents(profile.analyticsSeats.api); + } + + return { + profileIds, + subscriptionIds, + invoiceIds, + openReceivableCents, + creditBalanceCents, + analyticsSeats, + }; +} + +function findDuplicateActiveSubscriptions(profiles) { + const byProduct = new Map(); + for (const profile of profiles) { + for (const subscription of activeSubscriptions(profile)) { + const product = normalize(subscription.product || subscription.plan || subscription.id); + if (!product) { + continue; + } + if (!byProduct.has(product)) { + byProduct.set(product, []); + } + byProduct.get(product).push({ + profileId: profile.id || 'unknown-profile', + subscriptionId: subscription.id || 'unknown-subscription', + product, + }); + } + } + + return [...byProduct.values()].filter((entries) => entries.length > 1); +} + +function shouldReviewCreditTransfer(profiles, generatedAt) { + const lots = profiles.flatMap((profile) => creditLots(profile)); + const total = lots.reduce((sum, lot) => sum + cents(lot.remainingCents), 0); + const expiringSoon = lots.some((lot) => fullDaysBetween(generatedAt, lot.expiresAt) <= 120); + return lots.length > 0 && (total >= 100000 || expiringSoon); +} + +function countSeverities(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] || 0) + 1; + return counts; + }, {critical: 0, high: 0, medium: 0}); +} + +function decide(findings) { + if (findings.some((finding) => finding.severity === 'critical')) { + return 'block-consolidation'; + } + if (findings.length > 0) { + return 'finance-review'; + } + return 'ready-to-consolidate'; +} + +function evaluateBillingCustomerConsolidation(input) { + const generatedAt = input.generatedAt || new Date(0).toISOString(); + const profiles = collectProfiles(input); + const findings = []; + const requiredActions = []; + const legalNames = uniqueFilled(profiles.map((profile) => profile.legalName)); + const taxIds = uniqueFilled(profiles.map((profile) => profile.taxId)); + const profileIds = profiles.map((profile) => profile.id || 'unknown-profile'); + const summaryBase = summarize(profiles); + + if (legalNames.length > 1 || taxIds.length > 1) { + addFinding( + findings, + requiredActions, + { + type: 'legal-entity-conflict', + severity: 'critical', + target: profileIds.join(', '), + legalNames, + taxIds, + message: 'Duplicate billing profiles resolve to conflicting legal names or tax identifiers.', + }, + action( + 'route_to_finance_legal_review', + input.institutionId || 'institution', + 'legal entity identity must be resolved before invoices, credits, or entitlements merge' + ) + ); + } + + const duplicateSubscriptions = findDuplicateActiveSubscriptions(profiles); + if (duplicateSubscriptions.length > 0) { + addFinding( + findings, + requiredActions, + { + type: 'duplicate-active-subscription', + severity: 'high', + target: duplicateSubscriptions.map((group) => group[0].product).join(', '), + subscriptions: duplicateSubscriptions.flat(), + message: 'More than one active subscription exists for the same revenue product.', + }, + action( + 'choose_surviving_subscription', + input.survivorProfileId || 'survivor-profile', + 'finance must prevent double billing or accidental entitlement loss before merge' + ) + ); + } + + if (shouldReviewCreditTransfer(profiles, generatedAt)) { + addFinding( + findings, + requiredActions, + { + type: 'credit-balance-transfer-required', + severity: 'high', + target: input.survivorProfileId || 'survivor-profile', + creditBalanceCents: summaryBase.creditBalanceCents, + message: 'Compute credit balances need explicit survivor-account transfer evidence.', + }, + action( + 'preserve_credit_lots', + input.survivorProfileId || 'survivor-profile', + 'prepaid or expiring compute credits must not disappear during customer consolidation' + ) + ); + } + + const unverifiedPaymentProfiles = profiles + .filter((profile) => profile.paymentEvidence && profile.paymentEvidence.verified === false) + .map((profile) => profile.id || 'unknown-profile'); + if (unverifiedPaymentProfiles.length > 0) { + addFinding( + findings, + requiredActions, + { + type: 'unverified-payment-evidence', + severity: 'high', + target: unverifiedPaymentProfiles.join(', '), + message: 'One or more merged profiles has unverified payment evidence.', + }, + action( + 'verify_payment_evidence', + unverifiedPaymentProfiles.join(', '), + 'survivor profile should not inherit paid features from unverified payment records' + ) + ); + } + + const receivableProfiles = profiles + .filter((profile) => openInvoices(profile).length > 0) + .map((profile) => profile.id || 'unknown-profile'); + if (receivableProfiles.length > 0) { + addFinding( + findings, + requiredActions, + { + type: 'open-receivable-preservation', + severity: findings.some((finding) => finding.type === 'legal-entity-conflict') ? 'high' : 'medium', + target: receivableProfiles.join(', '), + openReceivableCents: summaryBase.openReceivableCents, + message: 'Open invoice balances must remain traceable after profile consolidation.', + }, + action( + 'preserve_open_receivables', + receivableProfiles.join(', '), + 'AR balances, due dates, and invoice ownership must survive billing profile merge' + ) + ); + } + + const severities = countSeverities(findings); + const decision = decide(findings); + const survivorDraft = { + survivorProfileId: input.survivorProfileId || profileIds[0] || 'survivor-profile', + profileIds: summaryBase.profileIds, + subscriptionIds: summaryBase.subscriptionIds, + invoiceIds: summaryBase.invoiceIds, + openReceivableCents: summaryBase.openReceivableCents, + creditBalanceCents: summaryBase.creditBalanceCents, + analyticsSeats: summaryBase.analyticsSeats, + holdReason: decision === 'block-consolidation' + ? 'Blocked until legal entity identity conflict is resolved.' + : '', + }; + + return { + institutionId: input.institutionId || 'unknown-institution', + generatedAt, + decision, + summary: { + profiles: profiles.length, + activeSubscriptions: profiles.reduce((sum, profile) => sum + activeSubscriptions(profile).length, 0), + duplicateSubscriptionGroups: duplicateSubscriptions.length, + openReceivableCents: summaryBase.openReceivableCents, + creditBalanceCents: summaryBase.creditBalanceCents, + requiredActions: requiredActions.length, + ...severities, + }, + survivorDraft, + findings, + requiredActions, + }; +} + +function buildFinanceReviewPacket(result) { + const lines = [ + '# Billing Customer Consolidation Guard', + '', + `Institution: ${result.institutionId}`, + `Decision: ${result.decision}`, + `Generated: ${result.generatedAt}`, + '', + '## Survivor Draft', + '', + `- Survivor profile: ${result.survivorDraft.survivorProfileId}`, + `- Profiles merged: ${result.survivorDraft.profileIds.join(', ')}`, + `- Subscriptions preserved: ${result.survivorDraft.subscriptionIds.join(', ') || 'none'}`, + `- Invoices preserved: ${result.survivorDraft.invoiceIds.join(', ') || 'none'}`, + `- Open receivables: ${formatMoney(result.summary.openReceivableCents)}`, + `- Preserved credit balance: ${formatMoney(result.summary.creditBalanceCents)}`, + `- Analytics seats: ${result.survivorDraft.analyticsSeats.dashboard} dashboard, ${result.survivorDraft.analyticsSeats.api} API`, + '', + ]; + + if (result.findings.length === 0) { + lines.push('No blocking billing consolidation risks detected.'); + } else { + lines.push('## Findings', ''); + for (const finding of result.findings) { + lines.push(`- **${finding.severity}** ${finding.type}: ${finding.message}`); + } + lines.push('', '## Required Actions', ''); + for (const item of result.requiredActions) { + lines.push(`- ${item.type} (${item.target}): ${item.reason}`); + } + } + + return `${lines.join('\n')}\n`; +} + +module.exports = { + evaluateBillingCustomerConsolidation, + buildFinanceReviewPacket, +}; diff --git a/billing-customer-consolidation-guard/reports/consolidation-packet.json b/billing-customer-consolidation-guard/reports/consolidation-packet.json new file mode 100644 index 00000000..ac5a305b --- /dev/null +++ b/billing-customer-consolidation-guard/reports/consolidation-packet.json @@ -0,0 +1,216 @@ +[ + { + "scenario": "legal-entity-conflict-with-open-ar", + "institutionId": "inst-northbridge", + "generatedAt": "2026-05-22T15:00:00Z", + "decision": "block-consolidation", + "summary": { + "profiles": 2, + "activeSubscriptions": 2, + "duplicateSubscriptionGroups": 0, + "openReceivableCents": 420000, + "creditBalanceCents": 0, + "requiredActions": 2, + "critical": 1, + "high": 1, + "medium": 0 + }, + "survivorDraft": { + "survivorProfileId": "bill-northbridge-primary", + "profileIds": [ + "bill-northbridge-primary", + "bill-northbridge-lab" + ], + "subscriptionIds": [ + "sub-pro-01", + "sub-lab-77" + ], + "invoiceIds": [ + "inv-100", + "inv-101" + ], + "openReceivableCents": 420000, + "creditBalanceCents": 0, + "analyticsSeats": { + "dashboard": 0, + "api": 0 + }, + "holdReason": "Blocked until legal entity identity conflict is resolved." + }, + "findings": [ + { + "type": "legal-entity-conflict", + "severity": "critical", + "target": "bill-northbridge-primary, bill-northbridge-lab", + "legalNames": [ + "northbridge research foundation", + "northbridge university" + ], + "taxIds": [ + "us-12-3456789", + "us-98-7654321" + ], + "message": "Duplicate billing profiles resolve to conflicting legal names or tax identifiers." + }, + { + "type": "open-receivable-preservation", + "severity": "high", + "target": "bill-northbridge-primary", + "openReceivableCents": 420000, + "message": "Open invoice balances must remain traceable after profile consolidation." + } + ], + "requiredActions": [ + { + "type": "route_to_finance_legal_review", + "target": "inst-northbridge", + "reason": "legal entity identity must be resolved before invoices, credits, or entitlements merge" + }, + { + "type": "preserve_open_receivables", + "target": "bill-northbridge-primary", + "reason": "AR balances, due dates, and invoice ownership must survive billing profile merge" + } + ] + }, + { + "scenario": "duplicate-analytics-subscription-and-credit-transfer", + "institutionId": "inst-riverton", + "generatedAt": "2026-05-22T15:00:00Z", + "decision": "finance-review", + "summary": { + "profiles": 2, + "activeSubscriptions": 2, + "duplicateSubscriptionGroups": 1, + "openReceivableCents": 90000, + "creditBalanceCents": 200000, + "requiredActions": 4, + "critical": 0, + "high": 3, + "medium": 1 + }, + "survivorDraft": { + "survivorProfileId": "bill-riverton-main", + "profileIds": [ + "bill-riverton-main", + "bill-riverton-shadow" + ], + "subscriptionIds": [ + "sub-main", + "sub-shadow" + ], + "invoiceIds": [ + "inv-shadow" + ], + "openReceivableCents": 90000, + "creditBalanceCents": 200000, + "analyticsSeats": { + "dashboard": 26, + "api": 7 + }, + "holdReason": "" + }, + "findings": [ + { + "type": "duplicate-active-subscription", + "severity": "high", + "target": "analytics-api", + "subscriptions": [ + { + "profileId": "bill-riverton-main", + "subscriptionId": "sub-main", + "product": "analytics-api" + }, + { + "profileId": "bill-riverton-shadow", + "subscriptionId": "sub-shadow", + "product": "analytics-api" + } + ], + "message": "More than one active subscription exists for the same revenue product." + }, + { + "type": "credit-balance-transfer-required", + "severity": "high", + "target": "bill-riverton-main", + "creditBalanceCents": 200000, + "message": "Compute credit balances need explicit survivor-account transfer evidence." + }, + { + "type": "unverified-payment-evidence", + "severity": "high", + "target": "bill-riverton-shadow", + "message": "One or more merged profiles has unverified payment evidence." + }, + { + "type": "open-receivable-preservation", + "severity": "medium", + "target": "bill-riverton-shadow", + "openReceivableCents": 90000, + "message": "Open invoice balances must remain traceable after profile consolidation." + } + ], + "requiredActions": [ + { + "type": "choose_surviving_subscription", + "target": "bill-riverton-main", + "reason": "finance must prevent double billing or accidental entitlement loss before merge" + }, + { + "type": "preserve_credit_lots", + "target": "bill-riverton-main", + "reason": "prepaid or expiring compute credits must not disappear during customer consolidation" + }, + { + "type": "verify_payment_evidence", + "target": "bill-riverton-shadow", + "reason": "survivor profile should not inherit paid features from unverified payment records" + }, + { + "type": "preserve_open_receivables", + "target": "bill-riverton-shadow", + "reason": "AR balances, due dates, and invoice ownership must survive billing profile merge" + } + ] + }, + { + "scenario": "clean-same-entity-merge", + "institutionId": "inst-eastlake", + "generatedAt": "2026-05-22T15:00:00Z", + "decision": "ready-to-consolidate", + "summary": { + "profiles": 2, + "activeSubscriptions": 2, + "duplicateSubscriptionGroups": 0, + "openReceivableCents": 0, + "creditBalanceCents": 50000, + "requiredActions": 0, + "critical": 0, + "high": 0, + "medium": 0 + }, + "survivorDraft": { + "survivorProfileId": "bill-eastlake-main", + "profileIds": [ + "bill-eastlake-main", + "bill-eastlake-legacy" + ], + "subscriptionIds": [ + "sub-eastlake-lab", + "sub-eastlake-compute" + ], + "invoiceIds": [ + "inv-eastlake-paid" + ], + "openReceivableCents": 0, + "creditBalanceCents": 50000, + "analyticsSeats": { + "dashboard": 7, + "api": 1 + }, + "holdReason": "" + }, + "findings": [], + "requiredActions": [] + } +] diff --git a/billing-customer-consolidation-guard/reports/demo.mp4 b/billing-customer-consolidation-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..4b1dffa06daaac0e9ad8ba0ef521356a4f781211 GIT binary patch literal 8315 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 literal 0 HcmV?d00001 diff --git a/billing-customer-consolidation-guard/reports/finance-review-packet.md b/billing-customer-consolidation-guard/reports/finance-review-packet.md new file mode 100644 index 00000000..1c2c93e5 --- /dev/null +++ b/billing-customer-consolidation-guard/reports/finance-review-packet.md @@ -0,0 +1,75 @@ +# Billing Customer Consolidation Guard + +Institution: inst-northbridge +Decision: block-consolidation +Generated: 2026-05-22T15:00:00Z + +## Survivor Draft + +- Survivor profile: bill-northbridge-primary +- Profiles merged: bill-northbridge-primary, bill-northbridge-lab +- Subscriptions preserved: sub-pro-01, sub-lab-77 +- Invoices preserved: inv-100, inv-101 +- Open receivables: $4200.00 +- Preserved credit balance: $0.00 +- Analytics seats: 0 dashboard, 0 API + +## Findings + +- **critical** legal-entity-conflict: Duplicate billing profiles resolve to conflicting legal names or tax identifiers. +- **high** open-receivable-preservation: Open invoice balances must remain traceable after profile consolidation. + +## Required Actions + +- route_to_finance_legal_review (inst-northbridge): legal entity identity must be resolved before invoices, credits, or entitlements merge +- preserve_open_receivables (bill-northbridge-primary): AR balances, due dates, and invoice ownership must survive billing profile merge + +--- +# Billing Customer Consolidation Guard + +Institution: inst-riverton +Decision: finance-review +Generated: 2026-05-22T15:00:00Z + +## Survivor Draft + +- Survivor profile: bill-riverton-main +- Profiles merged: bill-riverton-main, bill-riverton-shadow +- Subscriptions preserved: sub-main, sub-shadow +- Invoices preserved: inv-shadow +- Open receivables: $900.00 +- Preserved credit balance: $2000.00 +- Analytics seats: 26 dashboard, 7 API + +## Findings + +- **high** duplicate-active-subscription: More than one active subscription exists for the same revenue product. +- **high** credit-balance-transfer-required: Compute credit balances need explicit survivor-account transfer evidence. +- **high** unverified-payment-evidence: One or more merged profiles has unverified payment evidence. +- **medium** open-receivable-preservation: Open invoice balances must remain traceable after profile consolidation. + +## Required Actions + +- choose_surviving_subscription (bill-riverton-main): finance must prevent double billing or accidental entitlement loss before merge +- preserve_credit_lots (bill-riverton-main): prepaid or expiring compute credits must not disappear during customer consolidation +- verify_payment_evidence (bill-riverton-shadow): survivor profile should not inherit paid features from unverified payment records +- preserve_open_receivables (bill-riverton-shadow): AR balances, due dates, and invoice ownership must survive billing profile merge + +--- +# Billing Customer Consolidation Guard + +Institution: inst-eastlake +Decision: ready-to-consolidate +Generated: 2026-05-22T15:00:00Z + +## Survivor Draft + +- Survivor profile: bill-eastlake-main +- Profiles merged: bill-eastlake-main, bill-eastlake-legacy +- Subscriptions preserved: sub-eastlake-lab, sub-eastlake-compute +- Invoices preserved: inv-eastlake-paid +- Open receivables: $0.00 +- Preserved credit balance: $500.00 +- Analytics seats: 7 dashboard, 1 API + +No blocking billing consolidation risks detected. diff --git a/billing-customer-consolidation-guard/reports/summary.svg b/billing-customer-consolidation-guard/reports/summary.svg new file mode 100644 index 00000000..adaaafd2 --- /dev/null +++ b/billing-customer-consolidation-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + + Billing Customer Consolidation Guard + Revenue-control packet for duplicate customer profile merges + + + Blocked + 1 + + + + Finance Review + 1 + + + + Ready + 1 + + Open receivables preserved: $5100.00 | Credit balances preserved: $2500.00 + Checks: legal identity, duplicate subscriptions, payment evidence, credit lots, invoices, analytics seats + Synthetic data only. No customer secrets, payment processor calls, bank data, ERP, SSO, or live billing systems. + diff --git a/billing-customer-consolidation-guard/requirements-map.md b/billing-customer-consolidation-guard/requirements-map.md new file mode 100644 index 00000000..81ec4c5c --- /dev/null +++ b/billing-customer-consolidation-guard/requirements-map.md @@ -0,0 +1,30 @@ +# Issue #20 Requirement Map + +## Tiered Subscription Billing + +- Preserves all active subscription IDs across duplicate customer profiles. +- Detects duplicate active subscriptions for the same product before double billing or accidental cancellation. +- Emits a survivor-profile draft so finance can choose which subscription remains authoritative. + +## AI Compute Billing + +- Preserves AI compute credit lots and remaining balances across profile consolidation. +- Flags large or expiring credit balances for finance review before credits move to the survivor account. +- Prevents paid AI usage from becoming detached from payment evidence during merge. + +## Licensing APIs And Analytics + +- Aggregates dashboard and API seats into the survivor draft for renewal and true-up review. +- Keeps analytics entitlements separate from subscription merge decisions so seat leakage is visible. + +## Institutional Invoicing + +- Preserves open receivables, paid invoices, and invoice ownership through profile consolidation. +- Blocks legal/tax identity conflicts before invoices and balances are merged. +- Routes unverified payment evidence to finance review before paid features or credits are inherited. + +## Review Evidence + +- `test.js` covers blocked, finance-review, and ready-to-consolidate outcomes. +- `demo.js` generates deterministic JSON, Markdown, SVG, and MP4 artifacts from synthetic data. +- No live Stripe, PayPal, bank, ACH, ERP, SSO, SCIM, accounting, or customer-data systems are used. diff --git a/billing-customer-consolidation-guard/sample-data.js b/billing-customer-consolidation-guard/sample-data.js new file mode 100644 index 00000000..b13cb115 --- /dev/null +++ b/billing-customer-consolidation-guard/sample-data.js @@ -0,0 +1,86 @@ +const scenarios = [ + { + name: 'legal-entity-conflict-with-open-ar', + institutionId: 'inst-northbridge', + generatedAt: '2026-05-22T15:00:00Z', + survivorProfileId: 'bill-northbridge-primary', + profiles: [ + { + id: 'bill-northbridge-primary', + legalName: 'Northbridge University', + taxId: 'US-12-3456789', + currency: 'USD', + subscriptions: [{id: 'sub-pro-01', product: 'institutional-pro', status: 'active'}], + invoices: [{id: 'inv-100', status: 'open', balanceCents: 420000, currency: 'USD'}], + }, + { + id: 'bill-northbridge-lab', + legalName: 'Northbridge Research Foundation', + taxId: 'US-98-7654321', + currency: 'USD', + subscriptions: [{id: 'sub-lab-77', product: 'lab-collaboration', status: 'active'}], + invoices: [{id: 'inv-101', status: 'paid', balanceCents: 0, currency: 'USD'}], + }, + ], + }, + { + name: 'duplicate-analytics-subscription-and-credit-transfer', + institutionId: 'inst-riverton', + generatedAt: '2026-05-22T15:00:00Z', + survivorProfileId: 'bill-riverton-main', + profiles: [ + { + id: 'bill-riverton-main', + legalName: 'Riverton Institute', + taxId: 'US-22-3333333', + currency: 'USD', + subscriptions: [{id: 'sub-main', product: 'analytics-api', status: 'active'}], + computeCreditLots: [{id: 'lot-main', remainingCents: 75000, expiresAt: '2026-12-31'}], + analyticsSeats: {dashboard: 20, api: 5}, + invoices: [], + }, + { + id: 'bill-riverton-shadow', + legalName: 'Riverton Institute', + taxId: 'US-22-3333333', + currency: 'USD', + subscriptions: [{id: 'sub-shadow', product: 'analytics-api', status: 'active'}], + computeCreditLots: [{id: 'lot-shadow', remainingCents: 125000, expiresAt: '2026-08-31'}], + analyticsSeats: {dashboard: 6, api: 2}, + coupons: [{code: 'CONSORTIUM-25', active: true}], + paymentEvidence: {verified: false}, + invoices: [{id: 'inv-shadow', status: 'open', balanceCents: 90000, currency: 'USD'}], + }, + ], + }, + { + name: 'clean-same-entity-merge', + institutionId: 'inst-eastlake', + generatedAt: '2026-05-22T15:00:00Z', + survivorProfileId: 'bill-eastlake-main', + profiles: [ + { + id: 'bill-eastlake-main', + legalName: 'Eastlake Lab', + taxId: 'US-44-5555555', + currency: 'USD', + subscriptions: [{id: 'sub-eastlake-lab', product: 'lab-collaboration', status: 'active'}], + computeCreditLots: [{id: 'lot-eastlake-a', remainingCents: 20000, expiresAt: '2026-11-30'}], + analyticsSeats: {dashboard: 5, api: 1}, + invoices: [{id: 'inv-eastlake-paid', status: 'paid', balanceCents: 0, currency: 'USD'}], + }, + { + id: 'bill-eastlake-legacy', + legalName: 'Eastlake Lab', + taxId: 'US-44-5555555', + currency: 'USD', + subscriptions: [{id: 'sub-eastlake-compute', product: 'ai-compute-pack', status: 'active'}], + computeCreditLots: [{id: 'lot-eastlake-b', remainingCents: 30000, expiresAt: '2026-10-31'}], + analyticsSeats: {dashboard: 2, api: 0}, + invoices: [], + }, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/billing-customer-consolidation-guard/test.js b/billing-customer-consolidation-guard/test.js new file mode 100644 index 00000000..83ed96f6 --- /dev/null +++ b/billing-customer-consolidation-guard/test.js @@ -0,0 +1,127 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateBillingCustomerConsolidation, + buildFinanceReviewPacket, +} = require('./index'); + +test('blocks consolidation when duplicate billing profiles disagree on legal entity identity', () => { + const result = evaluateBillingCustomerConsolidation({ + institutionId: 'inst-northbridge', + generatedAt: '2026-05-22T15:00:00Z', + survivorProfileId: 'bill-northbridge-primary', + profiles: [ + { + id: 'bill-northbridge-primary', + legalName: 'Northbridge University', + taxId: 'US-12-3456789', + currency: 'USD', + subscriptions: [{id: 'sub-pro-01', product: 'institutional-pro', status: 'active'}], + invoices: [{id: 'inv-100', status: 'open', balanceCents: 420000, currency: 'USD'}], + }, + { + id: 'bill-northbridge-lab', + legalName: 'Northbridge Research Foundation', + taxId: 'US-98-7654321', + currency: 'USD', + subscriptions: [{id: 'sub-lab-77', product: 'lab-collaboration', status: 'active'}], + invoices: [{id: 'inv-101', status: 'paid', balanceCents: 0, currency: 'USD'}], + }, + ], + }); + + assert.equal(result.decision, 'block-consolidation'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['legal-entity-conflict', 'open-receivable-preservation'] + ); + assert.equal(result.summary.openReceivableCents, 420000); + assert.match(result.survivorDraft.holdReason, /legal entity/i); +}); + +test('routes duplicate active subscriptions and credit transfers to finance review', () => { + const result = evaluateBillingCustomerConsolidation({ + institutionId: 'inst-riverton', + generatedAt: '2026-05-22T15:00:00Z', + survivorProfileId: 'bill-riverton-main', + profiles: [ + { + id: 'bill-riverton-main', + legalName: 'Riverton Institute', + taxId: 'US-22-3333333', + currency: 'USD', + subscriptions: [{id: 'sub-main', product: 'analytics-api', status: 'active'}], + computeCreditLots: [{id: 'lot-main', remainingCents: 75000, expiresAt: '2026-12-31'}], + analyticsSeats: {dashboard: 20, api: 5}, + invoices: [], + }, + { + id: 'bill-riverton-shadow', + legalName: 'Riverton Institute', + taxId: 'US-22-3333333', + currency: 'USD', + subscriptions: [{id: 'sub-shadow', product: 'analytics-api', status: 'active'}], + computeCreditLots: [{id: 'lot-shadow', remainingCents: 125000, expiresAt: '2026-08-31'}], + analyticsSeats: {dashboard: 6, api: 2}, + coupons: [{code: 'CONSORTIUM-25', active: true}], + paymentEvidence: {verified: false}, + invoices: [{id: 'inv-shadow', status: 'open', balanceCents: 90000, currency: 'USD'}], + }, + ], + }); + + assert.equal(result.decision, 'finance-review'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + [ + 'duplicate-active-subscription', + 'credit-balance-transfer-required', + 'unverified-payment-evidence', + 'open-receivable-preservation', + ] + ); + assert.equal(result.summary.creditBalanceCents, 200000); + assert.equal(result.summary.openReceivableCents, 90000); + assert.deepEqual(result.survivorDraft.analyticsSeats, {dashboard: 26, api: 7}); + assert.deepEqual(result.survivorDraft.subscriptionIds.sort(), ['sub-main', 'sub-shadow']); +}); + +test('approves clean same-entity consolidation while preserving invoices and entitlements', () => { + const result = evaluateBillingCustomerConsolidation({ + institutionId: 'inst-eastlake', + generatedAt: '2026-05-22T15:00:00Z', + survivorProfileId: 'bill-eastlake-main', + profiles: [ + { + id: 'bill-eastlake-main', + legalName: 'Eastlake Lab', + taxId: 'US-44-5555555', + currency: 'USD', + subscriptions: [{id: 'sub-eastlake-lab', product: 'lab-collaboration', status: 'active'}], + computeCreditLots: [{id: 'lot-eastlake-a', remainingCents: 20000, expiresAt: '2026-11-30'}], + analyticsSeats: {dashboard: 5, api: 1}, + invoices: [{id: 'inv-eastlake-paid', status: 'paid', balanceCents: 0, currency: 'USD'}], + }, + { + id: 'bill-eastlake-legacy', + legalName: 'Eastlake Lab', + taxId: 'US-44-5555555', + currency: 'USD', + subscriptions: [{id: 'sub-eastlake-compute', product: 'ai-compute-pack', status: 'active'}], + computeCreditLots: [{id: 'lot-eastlake-b', remainingCents: 30000, expiresAt: '2026-10-31'}], + analyticsSeats: {dashboard: 2, api: 0}, + invoices: [], + }, + ], + }); + const packet = buildFinanceReviewPacket(result); + + assert.equal(result.decision, 'ready-to-consolidate'); + assert.equal(result.findings.length, 0); + assert.equal(result.summary.creditBalanceCents, 50000); + assert.deepEqual(result.survivorDraft.profileIds.sort(), ['bill-eastlake-legacy', 'bill-eastlake-main']); + assert.match(packet, /Billing Customer Consolidation Guard/); + assert.match(packet, /ready-to-consolidate/); + assert.match(packet, /Preserved credit balance: \$500\.00/); +});