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 00000000..4b1dffa0 Binary files /dev/null and b/billing-customer-consolidation-guard/reports/demo.mp4 differ 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/); +});