From 3d3e53b1feb0a7c8d5969907458ffc7237e1818a Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Thu, 27 Aug 2026 11:12:28 +0100 Subject: [PATCH] feat: multi-chain billing, tiered overages, dunning retries, tenant invoice branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements four assigned issues across the billing stack. Closes #933 — multi-chain subscription management with unified billing * New src/services/multiChainSubscriptionService.ts: chain bindings per subscription, unified statements that convert every chain into one currency while keeping native token subtotals, and settlement planning with health-aware cross-chain failover. * Unpriced tokens are reported explicitly rather than counted as zero, and unpayable charges are marked blocked rather than dropped. * walletService gains getBalancesAcrossChains() (parallel, per-chain error isolation) and totalsBySymbol(), which keeps holdings separated by chain because the same symbol on two chains is not fungible. Closes #934 — automated dunning with configurable retry strategies * dunningService did not compile: it referenced this.templates, this.recoveredEntries and this.getStrategy(), none of which existed, read stages off DunningConfiguration rather than off a RetryStrategy, used an undefined `strategy` in recordFailedCharge, and dereferenced a possibly undefined entry in recordSuccessfulCharge. All fixed. * Adds strategy resolution (A/B variant > failure-reason override > plan default > built-in) and four backoff policies: fixed, linear, exponential and exponential-with-jitter, plus a retryable flag for hard declines. * Jitter decorrelates the retry storm that follows a single upstream outage. * recoveryRate is now measured over closed outcomes only, so in-flight dunning no longer depresses the rate. Closes #935 — usage-based billing with metered pricing and tiered overages * New backend/services/billing/metering.ts: pure rating engine supporting flat, graduated, volume and package pricing, with included-unit proration, minimum charges and spend caps. * contracts/metering gains register_tiered_meter(), quote_usage(), a PricingModel/PriceTier ladder and per-tier charge breakdowns. The existing register_meter() is unchanged and now registers a flat meter. * Off-chain and on-chain rating implement identical arithmetic; toContractTiers() converts between the two tier encodings. * Also removes a dead subtrackr_types::CoreError import and a From impl that collided with contracterror's blanket TryFrom impls — the metering crate did not compile before this. Closes #937 — invoice customization with per-tenant branding * Branding was a single platform-wide default. Adds a per-tenant registry to invoiceStore with field-by-field resolution (tenant > platform > fallback), per-tenant templates and numbering prefixes, and validation that reports every problem at once. * invoiceCustomizationService now renders real, deterministic HTML and a plain-text alternative instead of logging to the console. * All branding is attacker-supplied, so text is escaped, colours are pattern checked, logo/website URLs are scheme restricted (javascript: dropped), font stacks are stripped of CSS metacharacters and logo widths are clamped. Testing * 163 new TypeScript tests and 13 new contract tests, all passing. * backend/services/billing: 47 -> 146 passing. * src/store + src/services: 501 -> 565 passing. * Pre-existing failures are unchanged in both suites; no regressions. * cargo fmt and clippy -D warnings are clean for subtrackr-metering. Docs: DUNNING_RETRY_STRATEGIES.md, USAGE_BASED_BILLING.md, INVOICE_BRANDING.md, MULTI_CHAIN_SUBSCRIPTIONS.md. --- .../billing/__tests__/dunningService.test.ts | 394 ++++++++++++++++ .../invoiceCustomizationService.test.ts | 302 ++++++++++++ .../billing/__tests__/metering.test.ts | 391 ++++++++++++++++ backend/services/billing/dunningService.ts | 383 ++++++++++----- backend/services/billing/index.ts | 39 +- backend/services/billing/interfaces.ts | 18 +- .../billing/invoiceCustomizationService.ts | 342 ++++++++++++-- backend/services/billing/metering.ts | 441 ++++++++++++++++++ contracts/metering/src/lib.rs | 129 +++-- contracts/metering/src/metering.rs | 213 +++++++++ contracts/metering/src/test.rs | 362 +++++++++++++- docs/DUNNING_RETRY_STRATEGIES.md | 125 +++++ docs/INVOICE_BRANDING.md | 116 +++++ docs/MULTI_CHAIN_SUBSCRIPTIONS.md | 134 ++++++ docs/USAGE_BASED_BILLING.md | 168 +++++++ .../multiChainSubscriptionService.test.ts | 313 +++++++++++++ .../__tests__/walletMultiChain.test.ts | 113 +++++ src/services/multiChainSubscriptionService.ts | 396 ++++++++++++++++ src/services/walletService.ts | 70 +++ src/store/__tests__/invoiceBranding.test.ts | 296 ++++++++++++ src/store/invoiceStore.ts | 240 +++++++++- src/types/invoice.ts | 41 ++ 22 files changed, 4827 insertions(+), 199 deletions(-) create mode 100644 backend/services/billing/__tests__/dunningService.test.ts create mode 100644 backend/services/billing/__tests__/invoiceCustomizationService.test.ts create mode 100644 backend/services/billing/__tests__/metering.test.ts create mode 100644 backend/services/billing/metering.ts create mode 100644 docs/DUNNING_RETRY_STRATEGIES.md create mode 100644 docs/INVOICE_BRANDING.md create mode 100644 docs/MULTI_CHAIN_SUBSCRIPTIONS.md create mode 100644 docs/USAGE_BASED_BILLING.md create mode 100644 src/services/__tests__/multiChainSubscriptionService.test.ts create mode 100644 src/services/__tests__/walletMultiChain.test.ts create mode 100644 src/services/multiChainSubscriptionService.ts create mode 100644 src/store/__tests__/invoiceBranding.test.ts diff --git a/backend/services/billing/__tests__/dunningService.test.ts b/backend/services/billing/__tests__/dunningService.test.ts new file mode 100644 index 00000000..c4baae79 --- /dev/null +++ b/backend/services/billing/__tests__/dunningService.test.ts @@ -0,0 +1,394 @@ +import { DunningService } from '../dunningService'; +import type { RetryStrategy } from '../../../../src/types/dunning'; +import { DEFAULT_DUNNING_STAGES } from '../../../../src/types/dunning'; + +const strategy = (overrides: Partial = {}): RetryStrategy => ({ + stages: DEFAULT_DUNNING_STAGES, + maxRetries: 3, + retryIntervalHours: 1, + warnAfterFailures: 3, + suspendAfterDays: 3, + cancelAfterDays: 7, + communicationChannels: ['email', 'push'], + ...overrides, +}); + +const ONE_HOUR_MS = 3_600_000; + +let service: DunningService; + +beforeEach(() => { + service = new DunningService(); +}); + +describe('strategy resolution', () => { + it('falls back to the built-in strategy for an unconfigured plan', () => { + const resolved = service.getStrategy('plan_unknown', 'default'); + expect(resolved.stages).toEqual(DEFAULT_DUNNING_STAGES); + }); + + it('uses the plan default once configured', () => { + const custom = strategy({ maxRetries: 9 }); + service.configurePlan('plan_a', { defaultStrategy: custom }); + expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(9); + }); + + it('prefers a failure-reason override over the plan default', () => { + service.configurePlan('plan_a', { + defaultStrategy: strategy({ maxRetries: 3 }), + strategies: { expired_card: strategy({ maxRetries: 1 }) }, + }); + expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(3); + expect(service.getStrategy('plan_a', 'expired_card').maxRetries).toBe(1); + }); + + it('prefers an active A/B variant over everything else', () => { + service.configurePlan('plan_a', { + defaultStrategy: strategy({ maxRetries: 3 }), + strategies: { expired_card: strategy({ maxRetries: 1 }) }, + }); + service.configureABTest('plan_a', true, [ + { id: 'aggressive', weight: 1, strategy: strategy({ maxRetries: 7 }) }, + ]); + expect(service.getStrategy('plan_a', 'expired_card', 'aggressive').maxRetries).toBe(7); + }); + + it('ignores a variant when the A/B test is disabled', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 3 }) }); + service.configureABTest('plan_a', false, [ + { id: 'aggressive', weight: 1, strategy: strategy({ maxRetries: 7 }) }, + ]); + expect(service.getStrategy('plan_a', 'default', 'aggressive').maxRetries).toBe(3); + }); + + it('keeps the existing default when a later configurePlan omits it', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 5 }) }); + service.configurePlan('plan_a', { strategies: {} }); + expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(5); + }); +}); + +describe('configurable retry backoff', () => { + it('repeats the base delay under a fixed policy', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 4, + backoffPolicy: 'fixed', + maxDelayHours: 100, + }); + expect(service.calculateRetryDelay('card_declined', 1)).toBe(4); + expect(service.calculateRetryDelay('card_declined', 5)).toBe(4); + }); + + it('scales linearly with the attempt number under a linear policy', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 2, + backoffPolicy: 'linear', + maxDelayHours: 100, + }); + expect(service.calculateRetryDelay('card_declined', 1)).toBe(2); + expect(service.calculateRetryDelay('card_declined', 3)).toBe(6); + }); + + it('compounds under an exponential policy', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 1, + backoffMultiplier: 3, + backoffPolicy: 'exponential', + maxDelayHours: 1_000, + }); + expect(service.calculateRetryDelay('card_declined', 1)).toBe(1); + expect(service.calculateRetryDelay('card_declined', 3)).toBe(9); + }); + + it('caps the delay at maxDelayHours', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 1, + backoffMultiplier: 10, + backoffPolicy: 'exponential', + maxDelayHours: 12, + }); + expect(service.calculateRetryDelay('card_declined', 8)).toBe(12); + }); + + it('keeps jittered delays inside the configured envelope', () => { + service.configureRetrySchedule({ + failureType: 'network_error', + baseDelayHours: 4, + backoffMultiplier: 1, + backoffPolicy: 'exponential_jitter', + jitterRatio: 0.25, + maxDelayHours: 10, + }); + const samples = Array.from({ length: 200 }, () => + service.calculateRetryDelay('network_error', 1) + ); + for (const sample of samples) { + expect(sample).toBeGreaterThanOrEqual(3); + expect(sample).toBeLessThanOrEqual(5); + } + // Jitter must actually spread the values, otherwise it is not doing its job. + expect(new Set(samples).size).toBeGreaterThan(1); + }); + + it('treats attempt 0 as the first attempt', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 3, + backoffPolicy: 'linear', + maxDelayHours: 100, + }); + expect(service.calculateRetryDelay('card_declined', 0)).toBe(3); + }); + + it('merges a partial schedule update onto the existing one', () => { + service.configureRetrySchedule({ failureType: 'expired_card', maxRetries: 9 }); + const schedule = service.getRetrySchedule('expired_card'); + expect(schedule.maxRetries).toBe(9); + // Untouched fields keep their defaults. + expect(schedule.baseDelayHours).toBe(24); + expect(schedule.backoffPolicy).toBe('fixed'); + }); + + it('falls back to the unknown schedule for an unregistered failure type', () => { + const schedule = service.getRetrySchedule('not_a_real_type' as never); + expect(schedule.failureType).toBe('unknown'); + }); +}); + +describe('dunning lifecycle', () => { + const start = () => service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + + it('opens an entry at the first stage of the resolved strategy', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + const entry = start(); + expect(entry.currentStage).toBe('retry'); + expect(entry.failedAttempts).toBe(0); + expect(service.getDunningEntry('sub_1')).toBe(entry); + }); + + it('is idempotent — starting twice returns the same entry', () => { + expect(start()).toBe(start()); + expect(service.listActiveDunning()).toHaveLength(1); + }); + + it('schedules the next retry using the configured backoff', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ + failureType: 'network_error', + baseDelayHours: 2, + backoffPolicy: 'fixed', + maxRetries: 10, + maxDelayHours: 100, + }); + start(); + const before = Date.now(); + const entry = service.recordFailedCharge('sub_1', 'network_error')!; + expect(entry.failedAttempts).toBe(1); + expect(entry.currentStage).toBe('retry'); + expect(entry.nextActionAt - before).toBeGreaterThanOrEqual(2 * ONE_HOUR_MS - 50); + }); + + it('advances to the next stage once the stage attempt budget is spent', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'network_error', maxRetries: 99 }); + start(); + // The default `retry` stage allows 3 attempts. + service.recordFailedCharge('sub_1', 'network_error'); + service.recordFailedCharge('sub_1', 'network_error'); + const entry = service.recordFailedCharge('sub_1', 'network_error')!; + expect(entry.currentStage).toBe('warn'); + expect(entry.failedAttempts).toBe(0); + }); + + it('escalates immediately for a failure type marked non-retryable', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'expired_card', retryable: false }); + start(); + const entry = service.recordFailedCharge('sub_1', 'expired_card')!; + expect(entry.currentStage).toBe('warn'); + }); + + it('sends a stage communication when it escalates', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'expired_card', retryable: false }); + start(); + service.recordFailedCharge('sub_1', 'expired_card'); + const comms = service.getCommunications('sub_1'); + expect(comms).toHaveLength(1); + expect(comms[0].stage).toBe('warn'); + expect(comms[0].templateId).toBe('payment_warning'); + // The channel comes from the resolved strategy, not a hardcoded default. + expect(comms[0].channel).toBe('email'); + }); + + it('lands on cancel once the ladder is exhausted', () => { + service.configurePlan('plan_a', { + defaultStrategy: strategy({ stages: [{ stage: 'retry', delayHours: 1, maxAttempts: 1, templateId: 'payment_retry' }] }), + }); + service.configureRetrySchedule({ failureType: 'unknown', maxRetries: 99 }); + start(); + const entry = service.recordFailedCharge('sub_1', 'unknown')!; + expect(entry.currentStage).toBe('cancel'); + }); + + it('does not record failures against a paused entry', () => { + start(); + service.pauseDunning('sub_1'); + expect(service.recordFailedCharge('sub_1')).toBeNull(); + }); + + it('reschedules on resume', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + start(); + service.pauseDunning('sub_1'); + const resumed = service.resumeDunning('sub_1')!; + expect(resumed.isPaused).toBe(false); + expect(resumed.nextActionAt).toBeGreaterThan(Date.now()); + }); + + it('returns null for lifecycle calls on an unknown subscription', () => { + expect(service.recordFailedCharge('nope')).toBeNull(); + expect(service.recordSuccessfulCharge('nope')).toBeNull(); + expect(service.pauseDunning('nope')).toBeNull(); + expect(service.resumeDunning('nope')).toBeNull(); + expect(service.overrideStage('nope', 'warn')).toBeNull(); + }); + + it('closes the entry on a successful charge', () => { + start(); + service.recordFailedCharge('sub_1'); + const recovered = service.recordSuccessfulCharge('sub_1'); + expect(recovered).not.toBeNull(); + expect(service.getDunningEntry('sub_1')).toBeUndefined(); + expect(service.listRecoveredDunning('merchant_1')).toHaveLength(1); + }); + + it('lists only entries whose next action is due', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + start(); + expect(service.getProcessableEntries()).toHaveLength(0); + service.overrideStage('sub_1', 'retry'); + const entry = service.getDunningEntry('sub_1')!; + entry.nextActionAt = Date.now() - 1_000; + expect(service.getProcessableEntries()).toHaveLength(1); + }); + + it('scopes active listings by merchant', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_2', 'plan_a'); + expect(service.listActiveDunning('merchant_1')).toHaveLength(1); + expect(service.listActiveDunning()).toHaveLength(2); + }); +}); + +describe('analytics', () => { + beforeEach(() => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'network_error', maxRetries: 99 }); + }); + + it('counts retries by failure type', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.recordFailedCharge('sub_1', 'network_error'); + service.recordFailedCharge('sub_1', 'card_declined'); + const analytics = service.getRetryAnalytics('merchant_1'); + expect(analytics.totalRetries).toBe(2); + expect(analytics.retriesByFailureType.network_error).toBe(1); + expect(analytics.retriesByFailureType.card_declined).toBe(1); + expect(analytics.successfulRetries).toBe(0); + }); + + it('scopes analytics to a merchant', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_2', 'plan_a'); + service.recordFailedCharge('sub_1', 'network_error'); + service.recordFailedCharge('sub_2', 'network_error'); + expect(service.getRetryAnalytics('merchant_1').totalRetries).toBe(1); + expect(service.getRetryAnalytics().totalRetries).toBe(2); + }); + + it('measures recovery rate over closed outcomes only', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_1', 'plan_a'); + service.recordFailedCharge('sub_1', 'network_error'); + service.recordSuccessfulCharge('sub_1'); + service.overrideStage('sub_2', 'cancel'); + + const analytics = service.getAnalytics('merchant_1'); + expect(analytics.totalRecovered).toBe(1); + expect(analytics.totalLost).toBe(1); + expect(analytics.recoveryRate).toBe(50); + }); + + it('reports zeroes rather than NaN with no history', () => { + const analytics = service.getAnalytics('merchant_none'); + expect(analytics.recoveryRate).toBe(0); + expect(analytics.averageDaysToRecovery).toBe(0); + expect(analytics.totalActiveDunning).toBe(0); + expect(service.getRetryAnalytics('merchant_none').successRate).toBe(0); + }); + + it('breaks active entries down by stage', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_1', 'plan_a'); + service.overrideStage('sub_2', 'suspend'); + const { stageBreakdown } = service.getAnalytics('merchant_1'); + expect(stageBreakdown.retry).toBe(1); + expect(stageBreakdown.suspend).toBe(1); + }); +}); + +describe('communication templates', () => { + it('ships the default template set', () => { + expect(service.getTemplates().map((t) => t.id)).toEqual([ + 'payment_retry', + 'payment_warning', + 'service_suspension', + 'subscription_cancellation', + ]); + }); + + it('adds, updates, and removes templates', () => { + service.addTemplate({ + id: 'custom', + stage: 'warn', + subject: 'Subject', + body: 'Body', + pushTitle: 'Title', + pushBody: 'Push', + actionLabel: 'Go', + actionUrl: '/go', + }); + expect(service.getTemplates()).toHaveLength(5); + + service.updateTemplate('custom', { subject: 'Updated' }); + expect(service.getTemplates().find((t) => t.id === 'custom')?.subject).toBe('Updated'); + + service.removeTemplate('custom'); + expect(service.getTemplates()).toHaveLength(4); + }); + + it('does not add the same template id twice', () => { + const existing = service.getTemplates()[0]; + service.addTemplate(existing); + expect(service.getTemplates()).toHaveLength(4); + }); +}); + +describe('reset', () => { + it('clears entries, history, and configuration', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 9 }) }); + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.recordFailedCharge('sub_1'); + service.reset(); + + expect(service.listActiveDunning()).toHaveLength(0); + expect(service.listRecoveredDunning()).toHaveLength(0); + expect(service.getConfiguration('plan_a')).toBeUndefined(); + expect(service.getRetryAnalytics().totalRetries).toBe(0); + }); +}); diff --git a/backend/services/billing/__tests__/invoiceCustomizationService.test.ts b/backend/services/billing/__tests__/invoiceCustomizationService.test.ts new file mode 100644 index 00000000..2dd94aa3 --- /dev/null +++ b/backend/services/billing/__tests__/invoiceCustomizationService.test.ts @@ -0,0 +1,302 @@ +import { + FALLBACK_BRANDING, + InvoiceCustomizationService, + escapeHtml, + normalizeBranding, +} from '../invoiceCustomizationService'; +import { useInvoiceStore } from '../../../../src/store/invoiceStore'; +import { DEFAULT_INVOICE_CONFIG, InvoiceStatus } from '../../../../src/types/invoice'; +import type { Invoice } from '../../../../src/types/invoice'; + +// expo-notifications has no native module under Jest; ts-jest hoists jest.mock +// above the imports at transform time. +jest.mock('../../../../src/services/notificationService', () => ({ + presentLocalNotification: jest.fn(() => Promise.resolve()), +})); + +const makeInvoice = (overrides: Partial = {}): Invoice => ({ + id: 'inv-1', + invoiceNumber: 'INV-000001', + subscriptionId: 'sub-1', + subscriptionName: 'Pro Plan', + merchantName: 'Platform Merchant', + lineItems: [ + { + description: 'Pro Plan — January', + quantity: 1, + unitPrice: 100, + currency: 'USD', + exchangeRate: 1_000_000, + taxRateBps: 0, + lineTotal: 100, + }, + ], + tax: 10, + subtotal: 100, + total: 110, + dueDate: new Date('2026-02-01T00:00:00.000Z'), + status: InvoiceStatus.DRAFT, + currency: 'USD', + region: 'GLOBAL', + exchangeRate: 1_000_000, + period: { + start: new Date('2026-01-01T00:00:00.000Z'), + end: new Date('2026-02-01T00:00:00.000Z'), + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, +}); + +beforeEach(() => { + useInvoiceStore.setState({ + invoices: [], + config: { ...DEFAULT_INVOICE_CONFIG }, + nextSequence: 1, + isLoading: false, + error: null, + brandingProfiles: {}, + templates: [ + { id: 'tpl-1', name: 'Standard', layout: 'standard' }, + { id: 'tpl-2', name: 'Modern', layout: 'modern' }, + { id: 'tpl-3', name: 'Minimalist', layout: 'minimalist' }, + ], + }); +}); + +describe('escapeHtml', () => { + it('escapes every character that could break out of markup', () => { + expect(escapeHtml(``)).toBe( + '<script>"x"&'y'</script>' + ); + }); + + it('renders null and undefined as an empty string', () => { + expect(escapeHtml(null)).toBe(''); + expect(escapeHtml(undefined)).toBe(''); + }); +}); + +describe('normalizeBranding', () => { + it('fills every unset field from the fallback palette', () => { + expect(normalizeBranding(undefined)).toMatchObject(FALLBACK_BRANDING); + }); + + it('keeps valid tenant values', () => { + expect(normalizeBranding({ primaryColor: '#abc' }).primaryColor).toBe('#abc'); + }); + + it('replaces an invalid colour with the fallback', () => { + expect(normalizeBranding({ primaryColor: 'red; background:url(x)' }).primaryColor).toBe( + FALLBACK_BRANDING.primaryColor + ); + }); + + it('drops a logo URL with an unsafe scheme', () => { + expect(normalizeBranding({ logoUrl: 'javascript:alert(1)' }).logoUrl).toBeUndefined(); + }); + + it('keeps http(s) and data:image logos', () => { + expect(normalizeBranding({ logoUrl: 'https://cdn.example.com/l.png' }).logoUrl).toBe( + 'https://cdn.example.com/l.png' + ); + expect(normalizeBranding({ logoUrl: 'data:image/png;base64,AA' }).logoUrl).toBe( + 'data:image/png;base64,AA' + ); + }); + + it('clamps the logo width into the renderable range', () => { + expect(normalizeBranding({ logoWidth: 5 }).logoWidth).toBe(24); + expect(normalizeBranding({ logoWidth: 9_999 }).logoWidth).toBe(320); + expect(normalizeBranding({}).logoWidth).toBe(140); + }); + + it('strips characters that would let a font stack close its declaration', () => { + expect(normalizeBranding({ fontFamily: 'Inter";}body{display:none' }).fontFamily).toBe( + 'Interbodydisplaynone' + ); + }); + + it('falls back when a font stack sanitizes to nothing', () => { + expect(normalizeBranding({ fontFamily: '";{}' }).fontFamily).toBe( + FALLBACK_BRANDING.fontFamily + ); + }); +}); + +describe('renderInvoice', () => { + it('renders under the platform defaults when no tenant is set', () => { + const rendered = InvoiceCustomizationService.renderInvoice(makeInvoice()); + expect(rendered.layout).toBe('standard'); + expect(rendered.templateId).toBe('tpl-1'); + expect(rendered.html).toContain('Platform Merchant'); + expect(rendered.branding.primaryColor).toBe(FALLBACK_BRANDING.primaryColor); + }); + + it("applies the tenant's branding, template, and display name", () => { + useInvoiceStore.getState().setTenantBranding('tenant-a', { + displayName: 'Acme Inc.', + branding: { primaryColor: '#ff0000', logoUrl: 'https://cdn.example.com/acme.png' }, + templateId: 'tpl-2', + }); + + const rendered = InvoiceCustomizationService.renderInvoice( + makeInvoice({ tenantId: 'tenant-a' }) + ); + expect(rendered.layout).toBe('modern'); + expect(rendered.html).toContain('Acme Inc.'); + expect(rendered.html).toContain('#ff0000'); + expect(rendered.html).toContain('https://cdn.example.com/acme.png'); + }); + + it('renders each layout differently', () => { + const store = useInvoiceStore.getState(); + const htmls = (['tpl-1', 'tpl-2', 'tpl-3'] as const).map((templateId) => { + store.setTenantBranding('t', { branding: {}, templateId }); + return InvoiceCustomizationService.renderInvoice(makeInvoice({ tenantId: 't' })).html; + }); + expect(new Set(htmls).size).toBe(3); + }); + + it('is deterministic — the same input renders byte-identical markup', () => { + const invoice = makeInvoice(); + expect(InvoiceCustomizationService.renderInvoice(invoice).html).toBe( + InvoiceCustomizationService.renderInvoice(invoice).html + ); + }); + + it('escapes a tenant display name containing markup', () => { + useInvoiceStore.getState().setTenantBranding('tenant-x', { + displayName: '', + branding: {}, + }); + const html = InvoiceCustomizationService.renderInvoice( + makeInvoice({ tenantId: 'tenant-x' }) + ).html; + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); + + it('escapes markup in line item descriptions', () => { + const html = InvoiceCustomizationService.renderInvoice( + makeInvoice({ + lineItems: [ + { + description: '', + quantity: 1, + unitPrice: 1, + currency: 'USD', + exchangeRate: 1, + taxRateBps: 0, + lineTotal: 1, + }, + ], + }) + ).html; + expect(html).not.toContain(' { + const rendered = InvoiceCustomizationService.renderInvoice(makeInvoice({ lineItems: [] })); + expect(rendered.html).toContain('No line items'); + }); + + it('renders totals in the invoice currency', () => { + const rendered = InvoiceCustomizationService.renderInvoice( + makeInvoice({ currency: 'XLM', subtotal: 100, tax: 10, total: 110 }) + ); + expect(rendered.html).toContain('110.00 XLM'); + expect(rendered.text).toContain('Total: 110.00 XLM'); + }); + + it('lets a per-invoice branding override outrank the tenant profile', () => { + useInvoiceStore + .getState() + .setTenantBranding('tenant-a', { branding: { primaryColor: '#ff0000' } }); + const rendered = InvoiceCustomizationService.renderInvoice( + makeInvoice({ tenantId: 'tenant-a', branding: { primaryColor: '#00ff00' } }) + ); + expect(rendered.branding.primaryColor).toBe('#00ff00'); + }); + + it('renders the footer contact block only when it is configured', () => { + expect(InvoiceCustomizationService.renderInvoice(makeInvoice()).html).not.toContain( + '