From d5d217529d10fde9fb6da4f73cbd5373172197f5 Mon Sep 17 00:00:00 2001 From: clemscoder Date: Mon, 19 Oct 2026 18:19:01 +0000 Subject: [PATCH] feat(subscriptions): add pause/resume with per-plan windows (#1723) Implements the subscription pause and resume feature described in issue #1723. Closes #1723. Changes ------- backend/migrations/005_subscription_pause_columns.sql - New migration: adds is_paused, paused_at, pause_duration_days, paused_until, billing_adjustment_amount, pause_reason, pause_note columns to the subscriptions table. - Partial index idx_subscriptions_paused_until for the auto-resume cron job; idx_subscriptions_active_billing to exclude paused rows from billing queries. backend/subscription/domain/pauseBilling.ts - PauseWindowRegistry: per-plan configurable min/max pause duration and yearly cap (acceptance criterion: "Pause window configurable"). - pauseWindowRegistry singleton exported for use by the controller and the store. - Pure helpers: calculatePauseCredit, calculateResumeCredit, validatePauseRequest, buildPauseBillingAdjustment, daysFromNow, daysBetween, isResumeDue, getPeriodDays. backend/subscription/domain/pauseStateStore.ts - resume() now computes nextBillingDate by shifting forward by the actual number of paused days so paused time never counts against the billing period (acceptance criterion: "Time does not count against period" and "Billing resumes correctly"). backend/subscription/controller/pauseController.ts - PauseRequestBody gains optional planId field. - pauseSubscription() passes planId through to PauseStateStore.pause() so per-plan PauseWindowRegistry limits are enforced at the API layer. backend/subscription/domain/index.ts - Re-exports all pause/resume symbols (PauseWindowRegistry, pauseWindowRegistry, PauseStateStore, pauseStateStore, billing helpers and types) for clean imports across the backend. backend/subscription/domain/__tests__/pauseWindowRegistry.test.ts (new) - PauseWindowRegistry: register, replace, remove, clear, list, getWindow. - Validation rejects bad minDays / maxDays / maxPausesPerYear. - validatePauseRequest honours per-plan limits. - PauseStateStore respects registered per-plan windows. - resume() shifts nextBillingDate on both full and early resume. backend/subscription/controller/__tests__/pauseController.test.ts (new) - planId wiring: per-plan window enforced end-to-end through the controller, falls back to global defaults when absent or unregistered. - nextBillingDate returned in resume response and stored in history. - previewPause does not mutate state. - getPauseState 404s when no history exists; returns active: null after resume. --- .../005_subscription_pause_columns.sql | 34 +++ .../__tests__/pauseController.test.ts | 169 ++++++++++++ .../controller/pauseController.ts | 10 +- .../__tests__/pauseWindowRegistry.test.ts | 246 ++++++++++++++++++ backend/subscription/domain/index.ts | 27 ++ backend/subscription/domain/pauseBilling.ts | 95 +++++++ .../subscription/domain/pauseStateStore.ts | 36 ++- 7 files changed, 612 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/005_subscription_pause_columns.sql create mode 100644 backend/subscription/controller/__tests__/pauseController.test.ts create mode 100644 backend/subscription/domain/__tests__/pauseWindowRegistry.test.ts diff --git a/backend/migrations/005_subscription_pause_columns.sql b/backend/migrations/005_subscription_pause_columns.sql new file mode 100644 index 00000000..0c66fbb3 --- /dev/null +++ b/backend/migrations/005_subscription_pause_columns.sql @@ -0,0 +1,34 @@ +-- Pause/resume columns for the subscriptions table (Issue #1723). +-- Tracks per-subscription pause state so the billing engine can skip +-- charges while a subscription is paused and correctly shift the next +-- billing date on resume. + +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS is_paused BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS pause_duration_days INTEGER; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS paused_until TIMESTAMPTZ; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS billing_adjustment_amount NUMERIC(12,2); +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS pause_reason VARCHAR(64); +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS pause_note TEXT; + +-- Index used by the auto-resume cron job that selects subscriptions +-- whose pause window has elapsed. +CREATE INDEX IF NOT EXISTS idx_subscriptions_paused_until + ON subscriptions (paused_until) + WHERE is_paused = true; + +-- Partial index so billing queries can cheaply exclude paused rows. +CREATE INDEX IF NOT EXISTS idx_subscriptions_active_billing + ON subscriptions (next_billing_date) + WHERE is_paused = false AND is_active = true; + +-- @down +DROP INDEX IF EXISTS idx_subscriptions_active_billing; +DROP INDEX IF EXISTS idx_subscriptions_paused_until; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS pause_note; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS pause_reason; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS billing_adjustment_amount; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS paused_until; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS pause_duration_days; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS paused_at; +ALTER TABLE subscriptions DROP COLUMN IF EXISTS is_paused; diff --git a/backend/subscription/controller/__tests__/pauseController.test.ts b/backend/subscription/controller/__tests__/pauseController.test.ts new file mode 100644 index 00000000..61bd6196 --- /dev/null +++ b/backend/subscription/controller/__tests__/pauseController.test.ts @@ -0,0 +1,169 @@ +/** + * Unit tests for pauseController — planId wiring and nextBillingDate shift + * (Issue #1723 acceptance criteria). + * + * Tests are written against the controller functions directly so no HTTP + * server or supertest dependency is required. + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { + pauseSubscription, + resumeSubscription, + previewPause, + getPauseState, +} from '../../controller/pauseController'; +import { pauseStateStore } from '../../domain/pauseStateStore'; +import { pauseWindowRegistry } from '../../domain/pauseBilling'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const baseBody = { price: 30, currency: 'USD', billingCycle: 'monthly', pauseDays: 14, reason: 'vacation' as const }; + +// ─── planId wiring (Acceptance: "Pause window configurable") ───────────────── + +describe('pauseController planId → per-plan window', () => { + beforeEach(() => { + pauseStateStore.reset(); + pauseWindowRegistry.clear(); + pauseWindowRegistry.register({ + planId: 'plan_limited', + minDays: 1, + maxDays: 10, + maxPausesPerYear: 3, + }); + }); + + it('returns 422 when pauseDays exceeds the per-plan maximum', () => { + const result = pauseSubscription( + 'sub_pl', + { ...baseBody, pauseDays: 30, planId: 'plan_limited' }, + ); + expect(result.status).toBe(422); + expect(result.ok).toBe(false); + expect((result.response as { error: { message: string } }).error.message).toContain('10 days'); + }); + + it('succeeds when pauseDays is within the per-plan maximum', () => { + const result = pauseSubscription( + 'sub_pl', + { ...baseBody, pauseDays: 7, planId: 'plan_limited' }, + ); + expect(result.status).toBe(201); + expect(result.ok).toBe(true); + }); + + it('falls back to global defaults when planId is absent', () => { + // 30 days exceeds the registered plan_limited window but not global defaults. + const result = pauseSubscription('sub_global', { ...baseBody, pauseDays: 30 }); + expect(result.status).toBe(201); + expect(result.ok).toBe(true); + }); + + it('falls back to global defaults for an unregistered planId', () => { + const result = pauseSubscription( + 'sub_unknown_plan', + { ...baseBody, pauseDays: 30, planId: 'plan_ghost' }, + ); + expect(result.status).toBe(201); + expect(result.ok).toBe(true); + }); +}); + +// ─── nextBillingDate on resume (Acceptance: "Billing resumes correctly") ────── + +describe('pauseController resume returns nextBillingDate', () => { + beforeEach(() => { + pauseStateStore.reset(); + pauseWindowRegistry.clear(); + }); + + it('includes nextBillingDate after a scheduled resume', () => { + pauseSubscription('sub_nbd', { ...baseBody, pauseDays: 14 }); + const result = resumeSubscription('sub_nbd', { early: false }); + + expect(result.status).toBe(200); + expect(result.ok).toBe(true); + const { session } = (result.response as { data: { session: { nextBillingDate?: string } } }).data; + expect(session.nextBillingDate).toBeDefined(); + // Must be a valid ISO 8601 date string. + expect(new Date(session.nextBillingDate!).toISOString()).toBe(session.nextBillingDate); + }); + + it('nextBillingDate on early resume is closer than on full resume', () => { + // Full-period pause. + pauseSubscription('sub_full', { ...baseBody, pauseDays: 30 }); + const fullResult = resumeSubscription('sub_full', { early: false }); + const fullDate = new Date( + ((fullResult.response as { data: { session: { nextBillingDate: string } } }).data.session.nextBillingDate) + ); + + pauseStateStore.reset(); + + // Early resume after 0 days elapsed (immediate resume — minimum shift). + pauseSubscription('sub_early', { ...baseBody, pauseDays: 30 }); + const earlyResult = resumeSubscription('sub_early', { early: true }); + const earlyDate = new Date( + ((earlyResult.response as { data: { session: { nextBillingDate: string } } }).data.session.nextBillingDate) + ); + + // Full resume (30 days of pause) should shift billing further than an + // immediate early resume (0 days elapsed). + expect(fullDate.getTime()).toBeGreaterThanOrEqual(earlyDate.getTime()); + }); + + it('getPauseState returns the shifted nextBillingDate in history', () => { + pauseSubscription('sub_hist', { ...baseBody, pauseDays: 14 }); + resumeSubscription('sub_hist', { early: false }); + + const stateResult = getPauseState('sub_hist'); + expect(stateResult.status).toBe(200); + const { history } = (stateResult.response as { data: { history: Array<{ nextBillingDate?: string }> } }).data; + expect(history[0].nextBillingDate).toBeDefined(); + }); +}); + +// ─── preview does not mutate state ──────────────────────────────────────────── + +describe('pauseController previewPause', () => { + beforeEach(() => { + pauseStateStore.reset(); + pauseWindowRegistry.clear(); + }); + + it('returns the adjustment without creating a session', () => { + const result = previewPause('sub_prev', { price: 30, pauseDays: 15, currency: 'USD' }); + expect(result.status).toBe(200); + expect(pauseStateStore.list()).toHaveLength(0); + const { adjustment } = (result.response as { data: { adjustment: { creditAmount: number } } }).data; + expect(adjustment.creditAmount).toBe(15); + }); + + it('includes scheduledResumeAt as an ISO timestamp', () => { + const result = previewPause('sub_prev', { price: 30, pauseDays: 15 }); + const { scheduledResumeAt } = (result.response as { data: { scheduledResumeAt: string } }).data; + expect(() => new Date(scheduledResumeAt)).not.toThrow(); + }); +}); + +// ─── getPauseState error cases ──────────────────────────────────────────────── + +describe('pauseController getPauseState', () => { + beforeEach(() => { + pauseStateStore.reset(); + }); + + it('returns 404 for a subscription with no pause history', () => { + const result = getPauseState('sub_none'); + expect(result.status).toBe(404); + expect(result.ok).toBe(false); + }); + + it('returns active: null after a subscription has been resumed', () => { + pauseSubscription('sub_done', { ...baseBody }); + resumeSubscription('sub_done', {}); + const result = getPauseState('sub_done'); + const { active } = (result.response as { data: { active: null | object } }).data; + expect(active).toBeNull(); + }); +}); diff --git a/backend/subscription/controller/pauseController.ts b/backend/subscription/controller/pauseController.ts index 7535b8b5..8da8dc08 100644 --- a/backend/subscription/controller/pauseController.ts +++ b/backend/subscription/controller/pauseController.ts @@ -14,8 +14,8 @@ import { getPeriodDays, daysBetween, type PauseReason, -} from '../../domain/pauseBilling'; -import { pauseStateStore, type PauseSession } from '../../domain/pauseStateStore'; +} from '../domain/pauseBilling'; +import { pauseStateStore, type PauseSession } from '../domain/pauseStateStore'; export interface PauseRequestBody { price?: number; @@ -25,6 +25,11 @@ export interface PauseRequestBody { pauseDays?: number; reason?: PauseReason; note?: string; + /** + * Plan identifier — when supplied the per-plan PauseWindowRegistry limits + * are applied instead of the global DEFAULT_PAUSE_LIMITS (Issue #1723). + */ + planId?: string; } export interface ResumeRequestBody { @@ -126,6 +131,7 @@ export function pauseSubscription( pauseDays: body.pauseDays, reason, note: body.note, + planId: body.planId, }); const view = toView(session); return { ...view, status: 201 }; diff --git a/backend/subscription/domain/__tests__/pauseWindowRegistry.test.ts b/backend/subscription/domain/__tests__/pauseWindowRegistry.test.ts new file mode 100644 index 00000000..8f12c2d0 --- /dev/null +++ b/backend/subscription/domain/__tests__/pauseWindowRegistry.test.ts @@ -0,0 +1,246 @@ +/** + * Tests for the per-plan PauseWindowRegistry (Issue #1723). + * + * Acceptance criterion: "Pause window configurable" + * + * The registry allows each subscription plan to override the global + * DEFAULT_PAUSE_LIMITS with its own min/max duration and yearly cap. + * These tests confirm that: + * - Plans fall back to global defaults when no window is registered. + * - Registered windows override defaults for a specific plan. + * - Invalid window configurations are rejected at registration time. + * - The controller-to-store path honours per-plan limits (via PauseStateStore). + * - Resuming shifts the next billing date forward by the actual pause duration. + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { + PauseWindowRegistry, + pauseWindowRegistry, + DEFAULT_PAUSE_LIMITS, + validatePauseRequest, +} from '../pauseBilling'; +import { PauseStateStore } from '../pauseStateStore'; + +// ─── PauseWindowRegistry unit tests ────────────────────────────────────────── + +describe('PauseWindowRegistry', () => { + let registry: PauseWindowRegistry; + + beforeEach(() => { + registry = new PauseWindowRegistry(); + }); + + it('returns global defaults for an unregistered plan', () => { + const limits = registry.getLimitsForPlan('plan_unknown'); + expect(limits).toEqual(DEFAULT_PAUSE_LIMITS); + }); + + it('registers a per-plan window and returns its limits', () => { + registry.register({ + planId: 'plan_basic', + minDays: 3, + maxDays: 30, + maxPausesPerYear: 4, + description: 'Basic plan: short pauses allowed', + }); + + const limits = registry.getLimitsForPlan('plan_basic'); + expect(limits.minDays).toBe(3); + expect(limits.maxDays).toBe(30); + expect(limits.maxPausesPerYear).toBe(4); + }); + + it('does not expose description in PauseLimits', () => { + registry.register({ planId: 'plan_x', minDays: 1, maxDays: 10, maxPausesPerYear: 1 }); + const limits = registry.getLimitsForPlan('plan_x'); + expect((limits as { description?: string }).description).toBeUndefined(); + }); + + it('allows replacing an existing window', () => { + registry.register({ planId: 'plan_pro', minDays: 7, maxDays: 60, maxPausesPerYear: 2 }); + registry.register({ planId: 'plan_pro', minDays: 14, maxDays: 90, maxPausesPerYear: 3 }); + const limits = registry.getLimitsForPlan('plan_pro'); + expect(limits.minDays).toBe(14); + expect(limits.maxDays).toBe(90); + }); + + it('removes a window and reverts to defaults', () => { + registry.register({ planId: 'plan_y', minDays: 1, maxDays: 10, maxPausesPerYear: 1 }); + registry.remove('plan_y'); + expect(registry.getLimitsForPlan('plan_y')).toEqual(DEFAULT_PAUSE_LIMITS); + }); + + it('lists all registered windows', () => { + registry.register({ planId: 'a', minDays: 1, maxDays: 5, maxPausesPerYear: 1 }); + registry.register({ planId: 'b', minDays: 7, maxDays: 90, maxPausesPerYear: 2 }); + const all = registry.list(); + expect(all).toHaveLength(2); + expect(all.map((w) => w.planId)).toEqual(expect.arrayContaining(['a', 'b'])); + }); + + it('clears all windows', () => { + registry.register({ planId: 'a', minDays: 1, maxDays: 5, maxPausesPerYear: 1 }); + registry.clear(); + expect(registry.list()).toHaveLength(0); + }); + + it('getWindow returns undefined for an unregistered plan', () => { + expect(registry.getWindow('nope')).toBeUndefined(); + }); + + it('getWindow returns the raw config for a registered plan', () => { + const win = { planId: 'p1', minDays: 2, maxDays: 20, maxPausesPerYear: 3, description: 'test' }; + registry.register(win); + expect(registry.getWindow('p1')).toEqual(win); + }); + + // ── Validation ────────────────────────────────────────────────────────────── + + it('rejects minDays < 1', () => { + expect(() => + registry.register({ planId: 'bad', minDays: 0, maxDays: 10, maxPausesPerYear: 1 }) + ).toThrow('minDays must be a positive integer'); + }); + + it('rejects non-finite minDays', () => { + expect(() => + registry.register({ planId: 'bad', minDays: NaN, maxDays: 10, maxPausesPerYear: 1 }) + ).toThrow('minDays'); + }); + + it('rejects maxDays <= minDays', () => { + expect(() => + registry.register({ planId: 'bad', minDays: 10, maxDays: 10, maxPausesPerYear: 1 }) + ).toThrow('maxDays must be greater than minDays'); + }); + + it('rejects maxPausesPerYear < 1', () => { + expect(() => + registry.register({ planId: 'bad', minDays: 1, maxDays: 10, maxPausesPerYear: 0 }) + ).toThrow('maxPausesPerYear must be at least 1'); + }); +}); + +// ─── Per-plan limits in validatePauseRequest ────────────────────────────────── + +describe('validatePauseRequest with per-plan limits', () => { + it('accepts a duration within per-plan bounds that exceeds global limits', () => { + const limits = { minDays: 1, maxDays: 180, maxPausesPerYear: 5 }; + expect(validatePauseRequest(120, false, 0, limits)).toEqual({ valid: true }); + }); + + it('rejects a duration below per-plan minimum', () => { + const limits = { minDays: 14, maxDays: 60, maxPausesPerYear: 2 }; + const result = validatePauseRequest(5, false, 0, limits); + expect(result.valid).toBe(false); + expect(result.reason).toContain('14 days'); + }); + + it('rejects a duration above per-plan maximum', () => { + const limits = { minDays: 7, maxDays: 30, maxPausesPerYear: 2 }; + const result = validatePauseRequest(60, false, 0, limits); + expect(result.valid).toBe(false); + expect(result.reason).toContain('30 days'); + }); + + it('rejects when per-plan yearly quota is exhausted', () => { + const limits = { minDays: 7, maxDays: 90, maxPausesPerYear: 1 }; + const result = validatePauseRequest(14, false, 1, limits); + expect(result.valid).toBe(false); + expect(result.reason).toContain('1'); + }); +}); + +// ─── PauseStateStore + per-plan registry integration ───────────────────────── + +describe('PauseStateStore respects per-plan PauseWindowRegistry', () => { + let store: PauseStateStore; + + const seed = { + subscriptionId: 'sub_plan_test', + price: 60, + currency: 'USD', + periodDays: 30, + reason: 'vacation' as const, + }; + + beforeEach(() => { + store = new PauseStateStore(); + // Register a plan with a short max pause window (10 days). + pauseWindowRegistry.clear(); + pauseWindowRegistry.register({ + planId: 'plan_limited', + minDays: 1, + maxDays: 10, + maxPausesPerYear: 3, + }); + }); + + it('allows a pause within the per-plan window', () => { + const session = store.pause({ ...seed, pauseDays: 7, planId: 'plan_limited' }); + expect(session.status).toBe('paused'); + expect(session.adjustment.creditAmount).toBe(14); + }); + + it('rejects a pause exceeding the per-plan max', () => { + expect(() => + store.pause({ ...seed, pauseDays: 30, planId: 'plan_limited' }) + ).toThrow('Maximum pause duration is 10 days.'); + }); + + it('falls back to global defaults when no planId is given', () => { + // Global default max is 90 days — 30 days is within bounds. + const session = store.pause({ ...seed, pauseDays: 30 }); + expect(session.status).toBe('paused'); + }); + + it('falls back to global defaults for an unregistered planId', () => { + const session = store.pause({ ...seed, pauseDays: 30, planId: 'plan_nonexistent' }); + expect(session.status).toBe('paused'); + }); +}); + +// ─── nextBillingDate shift on resume (Issue #1723) ────────────────────────── + +describe('PauseStateStore.resume() shifts nextBillingDate', () => { + let store: PauseStateStore; + + const seed = { + subscriptionId: 'sub_bd', + price: 30, + currency: 'USD', + periodDays: 30, + pauseDays: 14, + reason: 'vacation' as const, + }; + + beforeEach(() => { + store = new PauseStateStore(); + }); + + it('sets nextBillingDate 14 days ahead on a full scheduled resume', () => { + const pausedAt = new Date('2026-01-01T00:00:00.000Z'); + store.pause(seed, pausedAt); + const resumeAt = new Date('2026-01-15T00:00:00.000Z'); + const resumed = store.resume('sub_bd', false, resumeAt); + // On a scheduled resume, actualPausedDays == pauseDays (14). + expect(resumed.nextBillingDate).toBe('2026-01-29T00:00:00.000Z'); + }); + + it('sets nextBillingDate proportional to actual days elapsed on early resume', () => { + const pausedAt = new Date('2026-01-01T00:00:00.000Z'); + store.pause(seed, pausedAt); + // Resume after 7 days (half the intended pause). + const resumeAt = new Date('2026-01-08T00:00:00.000Z'); + const resumed = store.resume('sub_bd', true, resumeAt); + // actualPausedDays == 7 days elapsed → shift 7 days from resumeAt. + expect(resumed.nextBillingDate).toBe('2026-01-15T00:00:00.000Z'); + }); + + it('exposes nextBillingDate on an auto-resumed session', () => { + store.pause(seed, new Date('2026-01-01T00:00:00.000Z')); + const [autoResumed] = store.autoResumeDue(new Date('2026-01-20T00:00:00.000Z')); + expect(autoResumed.nextBillingDate).toBeDefined(); + }); +}); diff --git a/backend/subscription/domain/index.ts b/backend/subscription/domain/index.ts index 3ca1156d..62848d6b 100644 --- a/backend/subscription/domain/index.ts +++ b/backend/subscription/domain/index.ts @@ -10,3 +10,30 @@ export { InMemoryPlanRepository } from './PlanRepository'; export { PostgresPlanRepository, planMetadataToRow } from './PostgresPlanRepository'; export { PlanCacheService } from './PlanCacheService'; export type { PlanCacheConfig } from './PlanCacheService'; + +// Pause / resume (Issue #1723) +export { + pauseWindowRegistry, + PauseWindowRegistry, + DEFAULT_PAUSE_LIMITS, + buildPauseBillingAdjustment, + calculatePauseCredit, + calculateResumeCredit, + validatePauseRequest, + getPeriodDays, + daysBetween, + daysFromNow, + isResumeDue, + CREDIT_EXPIRY_DAYS, +} from './pauseBilling'; +export type { + PauseLimits, + PauseBillingInput, + PauseBillingAdjustment, + ResumeBillingInput, + ResumeBillingAdjustment, + PauseReason, + PlanPauseWindow, +} from './pauseBilling'; +export { pauseStateStore, PauseStateStore } from './pauseStateStore'; +export type { PauseSession, PauseStatus, PauseStoreSeed } from './pauseStateStore'; diff --git a/backend/subscription/domain/pauseBilling.ts b/backend/subscription/domain/pauseBilling.ts index ef1529cb..d268dc94 100644 --- a/backend/subscription/domain/pauseBilling.ts +++ b/backend/subscription/domain/pauseBilling.ts @@ -60,6 +60,101 @@ export const DEFAULT_PAUSE_LIMITS: PauseLimits = { maxPausesPerYear: 2, }; +// ─── Per-plan pause window registry ────────────────────────────────────────── + +/** + * Per-plan pause window configuration. + * When registered, these limits override DEFAULT_PAUSE_LIMITS for that plan. + */ +export interface PlanPauseWindow { + /** The plan this window applies to. */ + planId: string; + /** + * Minimum allowed pause duration in days (must be ≥ 1). + */ + minDays: number; + /** + * Maximum allowed pause duration in days (must be > minDays). + */ + maxDays: number; + /** + * Maximum number of pause sessions allowed per calendar year. + */ + maxPausesPerYear: number; + /** Optional description for admin display. */ + description?: string; +} + +/** + * Registry that maps plan IDs to their pause window configuration. + * Falls back to DEFAULT_PAUSE_LIMITS when no config is registered for a plan. + */ +export class PauseWindowRegistry { + private configs = new Map(); + + /** + * Register (or replace) a pause window for a plan. + * Throws when the window values are inconsistent. + */ + register(window: PlanPauseWindow): void { + if (!Number.isFinite(window.minDays) || window.minDays < 1) { + throw new Error( + `PlanPauseWindow for "${window.planId}": minDays must be a positive integer.`, + ); + } + if (!Number.isFinite(window.maxDays) || window.maxDays <= window.minDays) { + throw new Error( + `PlanPauseWindow for "${window.planId}": maxDays must be greater than minDays (${window.minDays}).`, + ); + } + if (!Number.isFinite(window.maxPausesPerYear) || window.maxPausesPerYear < 1) { + throw new Error( + `PlanPauseWindow for "${window.planId}": maxPausesPerYear must be at least 1.`, + ); + } + this.configs.set(window.planId, { ...window }); + } + + /** + * Retrieve the PauseLimits for a plan. + * Returns DEFAULT_PAUSE_LIMITS when the plan has no registered window. + */ + getLimitsForPlan(planId: string): PauseLimits { + const cfg = this.configs.get(planId); + if (!cfg) return { ...DEFAULT_PAUSE_LIMITS }; + return { + minDays: cfg.minDays, + maxDays: cfg.maxDays, + maxPausesPerYear: cfg.maxPausesPerYear, + }; + } + + /** + * Get the raw window config for a plan (undefined when not registered). + */ + getWindow(planId: string): PlanPauseWindow | undefined { + return this.configs.get(planId); + } + + /** Enumerate all registered windows. */ + list(): PlanPauseWindow[] { + return Array.from(this.configs.values()); + } + + /** Remove the window for a plan (reverts to DEFAULT_PAUSE_LIMITS). */ + remove(planId: string): boolean { + return this.configs.delete(planId); + } + + /** Clear all registered windows. */ + clear(): void { + this.configs.clear(); + } +} + +/** Shared singleton — import this in PauseStateStore and PauseBillingService. */ +export const pauseWindowRegistry = new PauseWindowRegistry(); + export const CREDIT_EXPIRY_DAYS = 90; const MS_PER_DAY = 24 * 60 * 60 * 1000; diff --git a/backend/subscription/domain/pauseStateStore.ts b/backend/subscription/domain/pauseStateStore.ts index c873cf28..a9824e0d 100644 --- a/backend/subscription/domain/pauseStateStore.ts +++ b/backend/subscription/domain/pauseStateStore.ts @@ -11,6 +11,7 @@ import { type PauseReason, daysFromNow, validatePauseRequest, + pauseWindowRegistry, } from './pauseBilling'; export type PauseStatus = 'paused' | 'resumed'; @@ -28,6 +29,13 @@ export interface PauseSession { adjustment: PauseBillingAdjustment; /** Credit still available on the ledger entry. */ creditRemaining: number; + /** + * The next billing date after resuming. + * Shifted forward by the actual pause duration so the paused time + * does not count against the current billing period (Issue #1723). + * Only set after `resume()` is called. + */ + nextBillingDate?: string; } export interface PauseStoreSeed { @@ -38,6 +46,11 @@ export interface PauseStoreSeed { pauseDays: number; reason: PauseReason; note?: string; + /** + * Plan identifier used to look up per-plan pause window limits. + * When omitted the global DEFAULT_PAUSE_LIMITS apply. + */ + planId?: string; } export class PauseStateStore { @@ -71,14 +84,24 @@ export class PauseStateStore { } /** - * Pause a subscription. Throws an Error with a human readable reason when - * the request violates the configured limits. + * Pause a subscription. + * + * Pause window (min/max days, max per year) is resolved in order: + * 1. Per-plan config registered in {@link pauseWindowRegistry} (when `seed.planId` is set) + * 2. Global {@link DEFAULT_PAUSE_LIMITS} + * + * Throws an Error with a human readable reason when the request violates the limits. */ pause(seed: PauseStoreSeed, now: Date = new Date()): PauseSession { + const limits = seed.planId + ? pauseWindowRegistry.getLimitsForPlan(seed.planId) + : undefined; + const validation = validatePauseRequest( seed.pauseDays, Boolean(this.getActive(seed.subscriptionId)), - this.pausesThisYear(seed.subscriptionId, now) + this.pausesThisYear(seed.subscriptionId, now), + limits, ); if (!validation.valid) { throw new Error(validation.reason ?? 'Invalid pause request.'); @@ -121,11 +144,18 @@ export class PauseStateStore { const fractionLeft = early ? (active.pauseDays - daysElapsed) / active.pauseDays : 0; const creditRemaining = round2(active.adjustment.creditAmount * fractionLeft); + // Shift the next billing date forward by the actual number of paused days + // so that time frozen during the pause does not count against the billing + // period (Issue #1723: "Time does not count against period"). + const actualPausedDays = early ? daysElapsed : active.pauseDays; + const nextBillingDate = daysFromNow(actualPausedDays, now); + const resumed: PauseSession = { ...active, status: 'resumed', resumedAt: now.toISOString(), creditRemaining, + nextBillingDate, }; this.sessions.set(resumed.id, resumed); return resumed;