Skip to content

Commit 3d3e53b

Browse files
committed
feat: multi-chain billing, tiered overages, dunning retries, tenant invoice branding
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.
1 parent 4a62952 commit 3d3e53b

22 files changed

Lines changed: 4827 additions & 199 deletions
Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
import { DunningService } from '../dunningService';
2+
import type { RetryStrategy } from '../../../../src/types/dunning';
3+
import { DEFAULT_DUNNING_STAGES } from '../../../../src/types/dunning';
4+
5+
const strategy = (overrides: Partial<RetryStrategy> = {}): RetryStrategy => ({
6+
stages: DEFAULT_DUNNING_STAGES,
7+
maxRetries: 3,
8+
retryIntervalHours: 1,
9+
warnAfterFailures: 3,
10+
suspendAfterDays: 3,
11+
cancelAfterDays: 7,
12+
communicationChannels: ['email', 'push'],
13+
...overrides,
14+
});
15+
16+
const ONE_HOUR_MS = 3_600_000;
17+
18+
let service: DunningService;
19+
20+
beforeEach(() => {
21+
service = new DunningService();
22+
});
23+
24+
describe('strategy resolution', () => {
25+
it('falls back to the built-in strategy for an unconfigured plan', () => {
26+
const resolved = service.getStrategy('plan_unknown', 'default');
27+
expect(resolved.stages).toEqual(DEFAULT_DUNNING_STAGES);
28+
});
29+
30+
it('uses the plan default once configured', () => {
31+
const custom = strategy({ maxRetries: 9 });
32+
service.configurePlan('plan_a', { defaultStrategy: custom });
33+
expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(9);
34+
});
35+
36+
it('prefers a failure-reason override over the plan default', () => {
37+
service.configurePlan('plan_a', {
38+
defaultStrategy: strategy({ maxRetries: 3 }),
39+
strategies: { expired_card: strategy({ maxRetries: 1 }) },
40+
});
41+
expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(3);
42+
expect(service.getStrategy('plan_a', 'expired_card').maxRetries).toBe(1);
43+
});
44+
45+
it('prefers an active A/B variant over everything else', () => {
46+
service.configurePlan('plan_a', {
47+
defaultStrategy: strategy({ maxRetries: 3 }),
48+
strategies: { expired_card: strategy({ maxRetries: 1 }) },
49+
});
50+
service.configureABTest('plan_a', true, [
51+
{ id: 'aggressive', weight: 1, strategy: strategy({ maxRetries: 7 }) },
52+
]);
53+
expect(service.getStrategy('plan_a', 'expired_card', 'aggressive').maxRetries).toBe(7);
54+
});
55+
56+
it('ignores a variant when the A/B test is disabled', () => {
57+
service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 3 }) });
58+
service.configureABTest('plan_a', false, [
59+
{ id: 'aggressive', weight: 1, strategy: strategy({ maxRetries: 7 }) },
60+
]);
61+
expect(service.getStrategy('plan_a', 'default', 'aggressive').maxRetries).toBe(3);
62+
});
63+
64+
it('keeps the existing default when a later configurePlan omits it', () => {
65+
service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 5 }) });
66+
service.configurePlan('plan_a', { strategies: {} });
67+
expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(5);
68+
});
69+
});
70+
71+
describe('configurable retry backoff', () => {
72+
it('repeats the base delay under a fixed policy', () => {
73+
service.configureRetrySchedule({
74+
failureType: 'card_declined',
75+
baseDelayHours: 4,
76+
backoffPolicy: 'fixed',
77+
maxDelayHours: 100,
78+
});
79+
expect(service.calculateRetryDelay('card_declined', 1)).toBe(4);
80+
expect(service.calculateRetryDelay('card_declined', 5)).toBe(4);
81+
});
82+
83+
it('scales linearly with the attempt number under a linear policy', () => {
84+
service.configureRetrySchedule({
85+
failureType: 'card_declined',
86+
baseDelayHours: 2,
87+
backoffPolicy: 'linear',
88+
maxDelayHours: 100,
89+
});
90+
expect(service.calculateRetryDelay('card_declined', 1)).toBe(2);
91+
expect(service.calculateRetryDelay('card_declined', 3)).toBe(6);
92+
});
93+
94+
it('compounds under an exponential policy', () => {
95+
service.configureRetrySchedule({
96+
failureType: 'card_declined',
97+
baseDelayHours: 1,
98+
backoffMultiplier: 3,
99+
backoffPolicy: 'exponential',
100+
maxDelayHours: 1_000,
101+
});
102+
expect(service.calculateRetryDelay('card_declined', 1)).toBe(1);
103+
expect(service.calculateRetryDelay('card_declined', 3)).toBe(9);
104+
});
105+
106+
it('caps the delay at maxDelayHours', () => {
107+
service.configureRetrySchedule({
108+
failureType: 'card_declined',
109+
baseDelayHours: 1,
110+
backoffMultiplier: 10,
111+
backoffPolicy: 'exponential',
112+
maxDelayHours: 12,
113+
});
114+
expect(service.calculateRetryDelay('card_declined', 8)).toBe(12);
115+
});
116+
117+
it('keeps jittered delays inside the configured envelope', () => {
118+
service.configureRetrySchedule({
119+
failureType: 'network_error',
120+
baseDelayHours: 4,
121+
backoffMultiplier: 1,
122+
backoffPolicy: 'exponential_jitter',
123+
jitterRatio: 0.25,
124+
maxDelayHours: 10,
125+
});
126+
const samples = Array.from({ length: 200 }, () =>
127+
service.calculateRetryDelay('network_error', 1)
128+
);
129+
for (const sample of samples) {
130+
expect(sample).toBeGreaterThanOrEqual(3);
131+
expect(sample).toBeLessThanOrEqual(5);
132+
}
133+
// Jitter must actually spread the values, otherwise it is not doing its job.
134+
expect(new Set(samples).size).toBeGreaterThan(1);
135+
});
136+
137+
it('treats attempt 0 as the first attempt', () => {
138+
service.configureRetrySchedule({
139+
failureType: 'card_declined',
140+
baseDelayHours: 3,
141+
backoffPolicy: 'linear',
142+
maxDelayHours: 100,
143+
});
144+
expect(service.calculateRetryDelay('card_declined', 0)).toBe(3);
145+
});
146+
147+
it('merges a partial schedule update onto the existing one', () => {
148+
service.configureRetrySchedule({ failureType: 'expired_card', maxRetries: 9 });
149+
const schedule = service.getRetrySchedule('expired_card');
150+
expect(schedule.maxRetries).toBe(9);
151+
// Untouched fields keep their defaults.
152+
expect(schedule.baseDelayHours).toBe(24);
153+
expect(schedule.backoffPolicy).toBe('fixed');
154+
});
155+
156+
it('falls back to the unknown schedule for an unregistered failure type', () => {
157+
const schedule = service.getRetrySchedule('not_a_real_type' as never);
158+
expect(schedule.failureType).toBe('unknown');
159+
});
160+
});
161+
162+
describe('dunning lifecycle', () => {
163+
const start = () => service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a');
164+
165+
it('opens an entry at the first stage of the resolved strategy', () => {
166+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
167+
const entry = start();
168+
expect(entry.currentStage).toBe('retry');
169+
expect(entry.failedAttempts).toBe(0);
170+
expect(service.getDunningEntry('sub_1')).toBe(entry);
171+
});
172+
173+
it('is idempotent — starting twice returns the same entry', () => {
174+
expect(start()).toBe(start());
175+
expect(service.listActiveDunning()).toHaveLength(1);
176+
});
177+
178+
it('schedules the next retry using the configured backoff', () => {
179+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
180+
service.configureRetrySchedule({
181+
failureType: 'network_error',
182+
baseDelayHours: 2,
183+
backoffPolicy: 'fixed',
184+
maxRetries: 10,
185+
maxDelayHours: 100,
186+
});
187+
start();
188+
const before = Date.now();
189+
const entry = service.recordFailedCharge('sub_1', 'network_error')!;
190+
expect(entry.failedAttempts).toBe(1);
191+
expect(entry.currentStage).toBe('retry');
192+
expect(entry.nextActionAt - before).toBeGreaterThanOrEqual(2 * ONE_HOUR_MS - 50);
193+
});
194+
195+
it('advances to the next stage once the stage attempt budget is spent', () => {
196+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
197+
service.configureRetrySchedule({ failureType: 'network_error', maxRetries: 99 });
198+
start();
199+
// The default `retry` stage allows 3 attempts.
200+
service.recordFailedCharge('sub_1', 'network_error');
201+
service.recordFailedCharge('sub_1', 'network_error');
202+
const entry = service.recordFailedCharge('sub_1', 'network_error')!;
203+
expect(entry.currentStage).toBe('warn');
204+
expect(entry.failedAttempts).toBe(0);
205+
});
206+
207+
it('escalates immediately for a failure type marked non-retryable', () => {
208+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
209+
service.configureRetrySchedule({ failureType: 'expired_card', retryable: false });
210+
start();
211+
const entry = service.recordFailedCharge('sub_1', 'expired_card')!;
212+
expect(entry.currentStage).toBe('warn');
213+
});
214+
215+
it('sends a stage communication when it escalates', () => {
216+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
217+
service.configureRetrySchedule({ failureType: 'expired_card', retryable: false });
218+
start();
219+
service.recordFailedCharge('sub_1', 'expired_card');
220+
const comms = service.getCommunications('sub_1');
221+
expect(comms).toHaveLength(1);
222+
expect(comms[0].stage).toBe('warn');
223+
expect(comms[0].templateId).toBe('payment_warning');
224+
// The channel comes from the resolved strategy, not a hardcoded default.
225+
expect(comms[0].channel).toBe('email');
226+
});
227+
228+
it('lands on cancel once the ladder is exhausted', () => {
229+
service.configurePlan('plan_a', {
230+
defaultStrategy: strategy({ stages: [{ stage: 'retry', delayHours: 1, maxAttempts: 1, templateId: 'payment_retry' }] }),
231+
});
232+
service.configureRetrySchedule({ failureType: 'unknown', maxRetries: 99 });
233+
start();
234+
const entry = service.recordFailedCharge('sub_1', 'unknown')!;
235+
expect(entry.currentStage).toBe('cancel');
236+
});
237+
238+
it('does not record failures against a paused entry', () => {
239+
start();
240+
service.pauseDunning('sub_1');
241+
expect(service.recordFailedCharge('sub_1')).toBeNull();
242+
});
243+
244+
it('reschedules on resume', () => {
245+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
246+
start();
247+
service.pauseDunning('sub_1');
248+
const resumed = service.resumeDunning('sub_1')!;
249+
expect(resumed.isPaused).toBe(false);
250+
expect(resumed.nextActionAt).toBeGreaterThan(Date.now());
251+
});
252+
253+
it('returns null for lifecycle calls on an unknown subscription', () => {
254+
expect(service.recordFailedCharge('nope')).toBeNull();
255+
expect(service.recordSuccessfulCharge('nope')).toBeNull();
256+
expect(service.pauseDunning('nope')).toBeNull();
257+
expect(service.resumeDunning('nope')).toBeNull();
258+
expect(service.overrideStage('nope', 'warn')).toBeNull();
259+
});
260+
261+
it('closes the entry on a successful charge', () => {
262+
start();
263+
service.recordFailedCharge('sub_1');
264+
const recovered = service.recordSuccessfulCharge('sub_1');
265+
expect(recovered).not.toBeNull();
266+
expect(service.getDunningEntry('sub_1')).toBeUndefined();
267+
expect(service.listRecoveredDunning('merchant_1')).toHaveLength(1);
268+
});
269+
270+
it('lists only entries whose next action is due', () => {
271+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
272+
start();
273+
expect(service.getProcessableEntries()).toHaveLength(0);
274+
service.overrideStage('sub_1', 'retry');
275+
const entry = service.getDunningEntry('sub_1')!;
276+
entry.nextActionAt = Date.now() - 1_000;
277+
expect(service.getProcessableEntries()).toHaveLength(1);
278+
});
279+
280+
it('scopes active listings by merchant', () => {
281+
service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a');
282+
service.startDunning('sub_2', 'subscriber_2', 'merchant_2', 'plan_a');
283+
expect(service.listActiveDunning('merchant_1')).toHaveLength(1);
284+
expect(service.listActiveDunning()).toHaveLength(2);
285+
});
286+
});
287+
288+
describe('analytics', () => {
289+
beforeEach(() => {
290+
service.configurePlan('plan_a', { defaultStrategy: strategy() });
291+
service.configureRetrySchedule({ failureType: 'network_error', maxRetries: 99 });
292+
});
293+
294+
it('counts retries by failure type', () => {
295+
service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a');
296+
service.recordFailedCharge('sub_1', 'network_error');
297+
service.recordFailedCharge('sub_1', 'card_declined');
298+
const analytics = service.getRetryAnalytics('merchant_1');
299+
expect(analytics.totalRetries).toBe(2);
300+
expect(analytics.retriesByFailureType.network_error).toBe(1);
301+
expect(analytics.retriesByFailureType.card_declined).toBe(1);
302+
expect(analytics.successfulRetries).toBe(0);
303+
});
304+
305+
it('scopes analytics to a merchant', () => {
306+
service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a');
307+
service.startDunning('sub_2', 'subscriber_2', 'merchant_2', 'plan_a');
308+
service.recordFailedCharge('sub_1', 'network_error');
309+
service.recordFailedCharge('sub_2', 'network_error');
310+
expect(service.getRetryAnalytics('merchant_1').totalRetries).toBe(1);
311+
expect(service.getRetryAnalytics().totalRetries).toBe(2);
312+
});
313+
314+
it('measures recovery rate over closed outcomes only', () => {
315+
service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a');
316+
service.startDunning('sub_2', 'subscriber_2', 'merchant_1', 'plan_a');
317+
service.recordFailedCharge('sub_1', 'network_error');
318+
service.recordSuccessfulCharge('sub_1');
319+
service.overrideStage('sub_2', 'cancel');
320+
321+
const analytics = service.getAnalytics('merchant_1');
322+
expect(analytics.totalRecovered).toBe(1);
323+
expect(analytics.totalLost).toBe(1);
324+
expect(analytics.recoveryRate).toBe(50);
325+
});
326+
327+
it('reports zeroes rather than NaN with no history', () => {
328+
const analytics = service.getAnalytics('merchant_none');
329+
expect(analytics.recoveryRate).toBe(0);
330+
expect(analytics.averageDaysToRecovery).toBe(0);
331+
expect(analytics.totalActiveDunning).toBe(0);
332+
expect(service.getRetryAnalytics('merchant_none').successRate).toBe(0);
333+
});
334+
335+
it('breaks active entries down by stage', () => {
336+
service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a');
337+
service.startDunning('sub_2', 'subscriber_2', 'merchant_1', 'plan_a');
338+
service.overrideStage('sub_2', 'suspend');
339+
const { stageBreakdown } = service.getAnalytics('merchant_1');
340+
expect(stageBreakdown.retry).toBe(1);
341+
expect(stageBreakdown.suspend).toBe(1);
342+
});
343+
});
344+
345+
describe('communication templates', () => {
346+
it('ships the default template set', () => {
347+
expect(service.getTemplates().map((t) => t.id)).toEqual([
348+
'payment_retry',
349+
'payment_warning',
350+
'service_suspension',
351+
'subscription_cancellation',
352+
]);
353+
});
354+
355+
it('adds, updates, and removes templates', () => {
356+
service.addTemplate({
357+
id: 'custom',
358+
stage: 'warn',
359+
subject: 'Subject',
360+
body: 'Body',
361+
pushTitle: 'Title',
362+
pushBody: 'Push',
363+
actionLabel: 'Go',
364+
actionUrl: '/go',
365+
});
366+
expect(service.getTemplates()).toHaveLength(5);
367+
368+
service.updateTemplate('custom', { subject: 'Updated' });
369+
expect(service.getTemplates().find((t) => t.id === 'custom')?.subject).toBe('Updated');
370+
371+
service.removeTemplate('custom');
372+
expect(service.getTemplates()).toHaveLength(4);
373+
});
374+
375+
it('does not add the same template id twice', () => {
376+
const existing = service.getTemplates()[0];
377+
service.addTemplate(existing);
378+
expect(service.getTemplates()).toHaveLength(4);
379+
});
380+
});
381+
382+
describe('reset', () => {
383+
it('clears entries, history, and configuration', () => {
384+
service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 9 }) });
385+
service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a');
386+
service.recordFailedCharge('sub_1');
387+
service.reset();
388+
389+
expect(service.listActiveDunning()).toHaveLength(0);
390+
expect(service.listRecoveredDunning()).toHaveLength(0);
391+
expect(service.getConfiguration('plan_a')).toBeUndefined();
392+
expect(service.getRetryAnalytics().totalRetries).toBe(0);
393+
});
394+
});

0 commit comments

Comments
 (0)