Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions backend/migrations/006_plan_trial_configs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-- Per-plan trial configuration for configurable trial periods (Issue #1722).
--
-- Each row describes the trial policy for a specific subscription plan:
-- how long the trial lasts and whether it auto-converts to a paid
-- subscription on expiry.

CREATE TABLE IF NOT EXISTS plan_trial_configs (
plan_id TEXT PRIMARY KEY REFERENCES plans (id) ON DELETE CASCADE,
trial_duration_days INTEGER NOT NULL CHECK (trial_duration_days > 0),
auto_convert_on_expiry BOOLEAN NOT NULL DEFAULT false,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE plan_trial_configs IS
'Per-plan trial configuration: duration in days and auto-conversion policy (Issue #1722).';

COMMENT ON COLUMN plan_trial_configs.trial_duration_days IS
'Number of days the trial period lasts for this plan. Must be a positive integer.';

COMMENT ON COLUMN plan_trial_configs.auto_convert_on_expiry IS
'When true the trial is automatically converted to a paid subscription at expiry '
'rather than being marked expired.';

-- Automatically update updated_at on row changes.
CREATE OR REPLACE FUNCTION update_plan_trial_configs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_plan_trial_configs_updated_at ON plan_trial_configs;
CREATE TRIGGER trg_plan_trial_configs_updated_at
BEFORE UPDATE ON plan_trial_configs
FOR EACH ROW
EXECUTE FUNCTION update_plan_trial_configs_updated_at();

-- @down
DROP TRIGGER IF EXISTS trg_plan_trial_configs_updated_at ON plan_trial_configs;
DROP FUNCTION IF EXISTS update_plan_trial_configs_updated_at();
DROP TABLE IF EXISTS plan_trial_configs;
256 changes: 254 additions & 2 deletions backend/subscription/__tests__/trialManagementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ describe('TrialManagementService', () => {
});

describe('processExpirations', () => {
it('should expire trials past their end date', () => {
it('should expire trials past their end date when no auto-convert config', () => {
const trial = service.startTrial({
subscriptionId: 'sub_001',
userId: 'user_001',
Expand All @@ -207,9 +207,10 @@ describe('TrialManagementService', () => {
});

const future = Date.now() + 10 * 24 * 60 * 60 * 1000;
const expired = service.processExpirations(future);
const { expired, converted } = service.processExpirations(future);

expect(expired).toHaveLength(1);
expect(converted).toHaveLength(0);
expect(expired[0].status).toBe('expired');
expect(expired[0].id).toBe(trial.id);
});
Expand Down Expand Up @@ -269,3 +270,254 @@ describe('TrialManagementService', () => {
});
});
});

// ─── Per-plan trial configuration tests (Issue #1722) ─────────────────────────

describe('registerPlanTrialConfig / getPlanTrialConfig', () => {
let service: TrialManagementService;
beforeEach(() => { service = new TrialManagementService(); });

it('registers a plan config and retrieves it', () => {
service.registerPlanTrialConfig({
planId: 'enterprise',
trialDurationDays: 30,
autoConvertOnExpiry: true,
description: 'Enterprise 30-day trial',
});

const config = service.getPlanTrialConfig('enterprise');
expect(config).toBeDefined();
expect(config!.trialDurationDays).toBe(30);
expect(config!.autoConvertOnExpiry).toBe(true);
});

it('returns undefined for an unregistered plan', () => {
expect(service.getPlanTrialConfig('unknown-plan')).toBeUndefined();
});

it('replaces an existing config on re-registration', () => {
service.registerPlanTrialConfig({ planId: 'basic', trialDurationDays: 7, autoConvertOnExpiry: false });
service.registerPlanTrialConfig({ planId: 'basic', trialDurationDays: 14, autoConvertOnExpiry: true });

const config = service.getPlanTrialConfig('basic');
expect(config!.trialDurationDays).toBe(14);
expect(config!.autoConvertOnExpiry).toBe(true);
});

it('throws for invalid trialDurationDays (zero)', () => {
expect(() =>
service.registerPlanTrialConfig({ planId: 'bad', trialDurationDays: 0, autoConvertOnExpiry: false }),
).toThrow('positive finite number');
});

it('throws for invalid trialDurationDays (negative)', () => {
expect(() =>
service.registerPlanTrialConfig({ planId: 'bad', trialDurationDays: -7, autoConvertOnExpiry: false }),
).toThrow('positive finite number');
});

it('lists all registered plan trial configs', () => {
service.registerPlanTrialConfig({ planId: 'basic', trialDurationDays: 7, autoConvertOnExpiry: false });
service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 21, autoConvertOnExpiry: true });

const configs = service.listPlanTrialConfigs();
expect(configs.length).toBeGreaterThanOrEqual(2);
expect(configs.map((c) => c.planId)).toContain('basic');
expect(configs.map((c) => c.planId)).toContain('pro');
});
});

describe('startTrial – per-plan duration resolution', () => {
let service: TrialManagementService;
beforeEach(() => { service = new TrialManagementService(); });

it('uses plan config duration when no durationDays param is given', () => {
service.registerPlanTrialConfig({
planId: 'enterprise',
trialDurationDays: 30,
autoConvertOnExpiry: false,
});

const trial = service.startTrial({
subscriptionId: 'sub_100',
userId: 'user_100',
planId: 'enterprise',
});

expect(trial.durationDays).toBe(30);
// endDate should be ~30 days from now
const expectedEnd = trial.startDate + 30 * 24 * 60 * 60 * 1000;
expect(trial.endDate).toBe(expectedEnd);
});

it('explicit durationDays overrides plan config', () => {
service.registerPlanTrialConfig({
planId: 'enterprise',
trialDurationDays: 30,
autoConvertOnExpiry: false,
});

const trial = service.startTrial({
subscriptionId: 'sub_101',
userId: 'user_101',
planId: 'enterprise',
durationDays: 7,
});

expect(trial.durationDays).toBe(7);
});

it('falls back to DEFAULT_TRIAL_DURATION_DAYS (14) when no config and no param', () => {
const trial = service.startTrial({
subscriptionId: 'sub_102',
userId: 'user_102',
planId: 'no-config-plan',
});

expect(trial.durationDays).toBe(14);
});

it('different plans get different durations from their configs', () => {
service.registerPlanTrialConfig({ planId: 'starter', trialDurationDays: 7, autoConvertOnExpiry: false });
service.registerPlanTrialConfig({ planId: 'business', trialDurationDays: 21, autoConvertOnExpiry: false });

const starter = service.startTrial({ subscriptionId: 'sub_103', userId: 'u1', planId: 'starter' });
const business = service.startTrial({ subscriptionId: 'sub_104', userId: 'u2', planId: 'business' });

expect(starter.durationDays).toBe(7);
expect(business.durationDays).toBe(21);
});
});

describe('processExpirations – auto-convert on expiry', () => {
let service: TrialManagementService;
beforeEach(() => { service = new TrialManagementService(); });

const MS_DAY = 24 * 60 * 60 * 1000;

it('auto-converts when plan config has autoConvertOnExpiry=true', () => {
service.registerPlanTrialConfig({
planId: 'pro',
trialDurationDays: 14,
autoConvertOnExpiry: true,
});

const trial = service.startTrial({
subscriptionId: 'sub_200',
userId: 'user_200',
planId: 'pro',
});

const future = trial.endDate + MS_DAY; // 1 day past end
const { converted, expired } = service.processExpirations(future);

expect(converted).toHaveLength(1);
expect(expired).toHaveLength(0);
expect(converted[0]!.id).toBe(trial.id);
expect(converted[0]!.status).toBe('converted');
expect(converted[0]!.conversionTrigger).toBe('automatic_time_based');
expect(converted[0]!.convertedAt).toBeDefined();
});

it('marks expired when plan config has autoConvertOnExpiry=false', () => {
service.registerPlanTrialConfig({
planId: 'free',
trialDurationDays: 7,
autoConvertOnExpiry: false,
});

const trial = service.startTrial({
subscriptionId: 'sub_201',
userId: 'user_201',
planId: 'free',
});

const future = trial.endDate + MS_DAY;
const { converted, expired } = service.processExpirations(future);

expect(expired).toHaveLength(1);
expect(converted).toHaveLength(0);
expect(expired[0]!.status).toBe('expired');
});

it('marks expired when no plan config is registered (default = no auto-convert)', () => {
const trial = service.startTrial({
subscriptionId: 'sub_202',
userId: 'user_202',
planId: 'unregistered-plan',
durationDays: 7,
});

const future = trial.endDate + MS_DAY;
const { converted, expired } = service.processExpirations(future);

expect(expired).toHaveLength(1);
expect(converted).toHaveLength(0);
});

it('handles mixed plans in one processExpirations pass', () => {
service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 7, autoConvertOnExpiry: true });
service.registerPlanTrialConfig({ planId: 'free', trialDurationDays: 7, autoConvertOnExpiry: false });

const t1 = service.startTrial({ subscriptionId: 'sub_210', userId: 'u1', planId: 'pro' });
const t2 = service.startTrial({ subscriptionId: 'sub_211', userId: 'u2', planId: 'free' });

const future = Math.max(t1.endDate, t2.endDate) + MS_DAY;
const { converted, expired } = service.processExpirations(future);

expect(converted.map((t) => t.id)).toContain(t1.id);
expect(expired.map((t) => t.id)).toContain(t2.id);
});

it('does not touch trials that have not yet expired', () => {
service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 30, autoConvertOnExpiry: true });

const trial = service.startTrial({
subscriptionId: 'sub_220',
userId: 'user_220',
planId: 'pro',
});

// Process right now — trial is still active
const { converted, expired } = service.processExpirations(trial.startDate);

expect(converted).toHaveLength(0);
expect(expired).toHaveLength(0);
expect(service.getTrial(trial.id)!.status).toBe('active');
});

it('does not re-process already-converted or expired trials', () => {
service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 7, autoConvertOnExpiry: true });

const trial = service.startTrial({ subscriptionId: 'sub_230', userId: 'u1', planId: 'pro' });
const future = trial.endDate + MS_DAY;

service.processExpirations(future); // first pass — converts
const second = service.processExpirations(future); // second pass — no-op

expect(second.converted).toHaveLength(0);
expect(second.expired).toHaveLength(0);
});

it('records trial_converted and payment_completed events on auto-convert', () => {
service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 7, autoConvertOnExpiry: true });

const trial = service.startTrial({ subscriptionId: 'sub_240', userId: 'u1', planId: 'pro' });
service.processExpirations(trial.endDate + MS_DAY);

const events = service.getFunnelEvents(trial.id);
expect(events.some((e) => e.eventType === 'trial_converted')).toBe(true);
expect(events.some((e) => e.eventType === 'payment_completed')).toBe(true);
});

it('records trial_expired event on normal expiry', () => {
service.registerPlanTrialConfig({ planId: 'free', trialDurationDays: 7, autoConvertOnExpiry: false });

const trial = service.startTrial({ subscriptionId: 'sub_250', userId: 'u1', planId: 'free' });
service.processExpirations(trial.endDate + MS_DAY);

const events = service.getFunnelEvents(trial.id);
expect(events.some((e) => e.eventType === 'trial_expired')).toBe(true);
expect(events.some((e) => e.eventType === 'trial_converted')).toBe(false);
});
});
Loading
Loading