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 = `
+`;
+
+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 @@
+
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/);
+});