Skip to content

Commit 22e78f8

Browse files
committed
Merge commit 'refs/pull/1045/head'
# Conflicts: # backend/services/billing/index.ts # contracts/subscription/src/lib.rs # contracts/subscription/src/trial.rs
2 parents 679694b + d59e834 commit 22e78f8

5 files changed

Lines changed: 1946 additions & 284 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import {
2+
TrialManagementService,
3+
DEFAULT_TRIAL_POLICY,
4+
} from '../trialManagementService';
5+
6+
describe('TrialManagementService', () => {
7+
let service: TrialManagementService;
8+
9+
beforeEach(() => {
10+
service = new TrialManagementService();
11+
});
12+
13+
describe('Trial Policy and Start', () => {
14+
it('initializes with default policy', () => {
15+
const policy = service.getPolicy('pro-plan');
16+
expect(policy.durationDays).toBe(14);
17+
expect(policy.gracePeriodDays).toBe(3);
18+
expect(policy.autoConvertOnExpiry).toBe(true);
19+
});
20+
21+
it('creates and enrolls a new trial subscription', () => {
22+
const trial = service.startTrial('user-123', 'pro-plan', 50, {
23+
durationDays: 7,
24+
earlyConversionDiscountBps: 2500,
25+
});
26+
27+
expect(trial.id).toBeDefined();
28+
expect(trial.userId).toBe('user-123');
29+
expect(trial.planId).toBe('pro-plan');
30+
expect(trial.status).toBe('active');
31+
expect(trial.autoConvert).toBe(true);
32+
expect(trial.conversionDiscountBps).toBe(2500);
33+
34+
const diffDays = Math.round((trial.endDate.getTime() - trial.startDate.getTime()) / 86_400_000);
35+
expect(diffDays).toBe(7);
36+
});
37+
});
38+
39+
describe('Propensity Scoring and Dynamic Incentives', () => {
40+
it('calculates disengaged category for zero activity', () => {
41+
const trial = service.startTrial('user-low', 'basic-plan', 20);
42+
const propensity = service.calculatePropensityScore(trial.id);
43+
44+
expect(propensity.score).toBeLessThan(25);
45+
expect(propensity.category).toBe('disengaged');
46+
});
47+
48+
it('calculates high propensity when subscriber has rich activity signals', () => {
49+
const trial = service.startTrial('user-power', 'enterprise-plan', 200);
50+
51+
service.recordActivity(trial.id, {
52+
featureUsageCount: 5,
53+
loginCount: 4,
54+
daysActive: 3,
55+
dashboardViews: 4,
56+
exportsTriggered: 2,
57+
});
58+
59+
const propensity = service.calculatePropensityScore(trial.id);
60+
expect(propensity.score).toBeGreaterThanOrEqual(75);
61+
expect(propensity.category).toBe('high_propensity');
62+
});
63+
64+
it('generates targeted retention incentive for at_risk users', () => {
65+
const trial = service.startTrial('user-risk', 'pro-plan', 50);
66+
67+
service.recordActivity(trial.id, {
68+
featureUsageCount: 1,
69+
loginCount: 1,
70+
daysActive: 1,
71+
dashboardViews: 2,
72+
});
73+
74+
const propensity = service.calculatePropensityScore(trial.id);
75+
expect(propensity.category).toBe('at_risk');
76+
expect(propensity.recommendedIncentive).toBeDefined();
77+
expect(propensity.recommendedIncentive?.discountPercentage).toBe(25);
78+
expect(propensity.recommendedIncentive?.bonusDays).toBe(5);
79+
});
80+
});
81+
82+
describe('Trial Extensions', () => {
83+
it('successfully extends active trial', () => {
84+
const trial = service.startTrial('user-ext', 'pro-plan', 50);
85+
const originalEndMs = trial.endDate.getTime();
86+
87+
const extended = service.extendTrial(trial.id, 7, 'Special high engagement reward');
88+
expect(extended).toBeDefined();
89+
expect(extended?.status).toBe('extended');
90+
expect(extended?.extensionCount).toBe(1);
91+
expect(extended?.endDate.getTime()).toBe(originalEndMs + 7 * 86_400_000);
92+
expect(extended?.metadata?.lastExtensionReason).toBe('Special high engagement reward');
93+
});
94+
95+
it('prevents extension beyond maximum allowed count', () => {
96+
const trial = service.startTrial('user-limit', 'pro-plan', 50, { maxExtensionsAllowed: 1 });
97+
98+
expect(service.extendTrial(trial.id, 3)).toBeDefined();
99+
// Second extension should be rejected
100+
expect(service.extendTrial(trial.id, 3)).toBeUndefined();
101+
});
102+
});
103+
104+
describe('Trial Conversion and Auto-Conversion Pipeline', () => {
105+
it('converts trial manually with early discount', () => {
106+
const trial = service.startTrial('user-conv', 'pro-plan', 100);
107+
const converted = service.convertTrial(trial.id, 'early_bird_click', 1500);
108+
109+
expect(converted).toBeDefined();
110+
expect(converted?.status).toBe('converted');
111+
expect(converted?.convertedAt).toBeDefined();
112+
expect(converted?.conversionTrigger).toBe('early_bird_click');
113+
expect(converted?.conversionDiscountBps).toBe(1500);
114+
});
115+
116+
it('cancels trial properly', () => {
117+
const trial = service.startTrial('user-cancel', 'pro-plan', 100);
118+
const cancelled = service.cancelTrial(trial.id, 'Competitor offer');
119+
120+
expect(cancelled?.status).toBe('cancelled');
121+
expect(cancelled?.metadata?.cancellationReason).toBe('Competitor offer');
122+
});
123+
124+
it('auto-converts expired trials when autoConvert is enabled', () => {
125+
const trial = service.startTrial('user-auto', 'pro-plan', 60, {
126+
durationDays: 7,
127+
gracePeriodDays: 2,
128+
autoConvertOnExpiry: true,
129+
});
130+
131+
// Advance date past grace period (10 days later)
132+
const futureDate = new Date(Date.now() + 10 * 86_400_000);
133+
const result = service.processTrialExpirations(futureDate);
134+
135+
expect(result.autoConverted.length).toBe(1);
136+
expect(result.autoConverted[0].id).toBe(trial.id);
137+
expect(result.autoConverted[0].status).toBe('converted');
138+
expect(result.autoConverted[0].conversionTrigger).toBe('auto_convert_expiry');
139+
});
140+
141+
it('expires trials without autoConvert enabled', () => {
142+
const trial = service.startTrial('user-noauto', 'pro-plan', 60, {
143+
durationDays: 5,
144+
gracePeriodDays: 1,
145+
autoConvertOnExpiry: false,
146+
});
147+
148+
const futureDate = new Date(Date.now() + 8 * 86_400_000);
149+
const result = service.processTrialExpirations(futureDate);
150+
151+
expect(result.expired.length).toBe(1);
152+
expect(result.expired[0].id).toBe(trial.id);
153+
expect(result.expired[0].status).toBe('expired');
154+
});
155+
});
156+
157+
describe('Reminders and Conversion Funnel Analytics', () => {
158+
it('schedules reminders upon trial creation', () => {
159+
const trial = service.startTrial('user-rem', 'pro-plan', 50);
160+
const pendingReminders = service.getPendingReminders(new Date(Date.now() + 20 * 86_400_000));
161+
162+
expect(pendingReminders.length).toBeGreaterThanOrEqual(3);
163+
const d1Reminder = pendingReminders.find((r) => r.reminderType === 'D-1');
164+
expect(d1Reminder?.attachedIncentive).toBeDefined();
165+
166+
const sent = service.markReminderSent(d1Reminder!.id);
167+
expect(sent).toBe(true);
168+
});
169+
170+
it('calculates comprehensive conversion funnel and revenue metrics', () => {
171+
// Create 4 trials
172+
const t1 = service.startTrial('user-1', 'pro-plan', 100, { earlyConversionDiscountBps: 2000 });
173+
const t2 = service.startTrial('user-2', 'pro-plan', 100, { earlyConversionDiscountBps: 1000 });
174+
const t3 = service.startTrial('user-3', 'pro-plan', 100);
175+
const t4 = service.startTrial('user-4', 'pro-plan', 100);
176+
177+
service.recordActivity(t1.id, { featureUsageCount: 3, loginCount: 2 });
178+
service.recordActivity(t2.id, { featureUsageCount: 1, loginCount: 1 });
179+
180+
service.convertTrial(t1.id, 'early_bird');
181+
service.convertTrial(t2.id, 'dashboard_cta');
182+
service.cancelTrial(t3.id);
183+
184+
const metrics = service.getFunnelMetrics('pro-plan');
185+
expect(metrics.totalStarted).toBe(4);
186+
expect(metrics.convertedCount).toBe(2);
187+
expect(metrics.cancelledCount).toBe(1);
188+
expect(metrics.conversionRatePercent).toBe(50);
189+
// t1 revenue: 100 * (1 - 0.2) = 80; t2 revenue: 100 * (1 - 0.1) = 90. Total = 170.
190+
expect(metrics.attributedRevenueUsd).toBe(170);
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)