diff --git a/src/store/__tests__/subscriptionPauseResume.test.ts b/src/store/__tests__/subscriptionPauseResume.test.ts new file mode 100644 index 00000000..8849f87b --- /dev/null +++ b/src/store/__tests__/subscriptionPauseResume.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { useSubscriptionStore } from '../subscriptionStore'; +import { BillingCycle, SubscriptionCategory } from '../../types/subscription'; +import { PauseReason, PauseState } from '../../types/pause'; + +jest.mock('@react-native-async-storage/async-storage', () => { + const store = new Map(); + return { + setItem: jest.fn((key: string, value: string) => { + store.set(key, value); + return Promise.resolve(); + }), + getItem: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)), + removeItem: jest.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + clear: jest.fn(() => { + store.clear(); + return Promise.resolve(); + }), + }; +}); + +jest.mock('../../services/notificationService', () => ({ + syncRenewalReminders: jest.fn(() => Promise.resolve()), + presentChargeSuccessNotification: jest.fn(() => Promise.resolve()), + presentChargeFailedNotification: jest.fn(() => Promise.resolve()), + presentLocalNotification: jest.fn(() => Promise.resolve()), + presentDunningRetryNotification: jest.fn(() => Promise.resolve()), + presentDunningWarningNotification: jest.fn(() => Promise.resolve()), + presentDunningSuspendedNotification: jest.fn(() => Promise.resolve()), + presentDunningCancelledNotification: jest.fn(() => Promise.resolve()), + presentDunningRecoveryNotification: jest.fn(() => Promise.resolve()), +})); + +describe('subscription pause/resume billing flow', () => { + beforeEach(() => { + useSubscriptionStore.setState({ + subscriptions: [], + creditAccounts: {}, + pauseHistory: [], + stats: { + totalActive: 0, + totalMonthlySpend: 0, + totalYearlySpend: 0, + categoryBreakdown: { + [SubscriptionCategory.STREAMING]: 0, + [SubscriptionCategory.SOFTWARE]: 0, + [SubscriptionCategory.GAMING]: 0, + [SubscriptionCategory.PRODUCTIVITY]: 0, + [SubscriptionCategory.FITNESS]: 0, + [SubscriptionCategory.EDUCATION]: 0, + [SubscriptionCategory.FINANCE]: 0, + [SubscriptionCategory.OTHER]: 0, + }, + }, + isLoading: false, + error: null, + prorationPreview: null, + creditMemos: {}, + }); + }); + + it('creates a pause record with prorated credit and marks the subscription inactive', () => { + const id = 'sub-1'; + useSubscriptionStore.setState({ + subscriptions: [ + { + id, + name: 'Netflix', + category: SubscriptionCategory.STREAMING, + price: 30, + currency: 'USD', + billingCycle: BillingCycle.MONTHLY, + nextBillingDate: new Date('2026-08-30T00:00:00.000Z'), + isActive: true, + notificationsEnabled: true, + isCryptoEnabled: false, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ], + }); + + const record = useSubscriptionStore.getState().pauseSubscription(id, 14, PauseReason.VACATION); + + expect(record.state).toBe(PauseState.PAUSED); + expect(record.creditAmount).toBe(14); + expect(record.creditRemaining).toBe(14); + expect(record.status).toBe('active'); + expect(useSubscriptionStore.getState().subscriptions[0].isActive).toBe(false); + expect(useSubscriptionStore.getState().getActivePause(id)?.subscriptionId).toBe(id); + }); + + it('resumes a paused subscription and shifts the next billing date', () => { + const id = 'sub-2'; + const originalNextBillingDate = new Date('2026-08-30T00:00:00.000Z'); + useSubscriptionStore.setState({ + subscriptions: [ + { + id, + name: 'Spotify', + category: SubscriptionCategory.OTHER, + price: 60, + currency: 'USD', + billingCycle: BillingCycle.MONTHLY, + nextBillingDate: originalNextBillingDate, + isActive: true, + notificationsEnabled: true, + isCryptoEnabled: false, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ], + }); + + useSubscriptionStore.getState().pauseSubscription(id, 14, PauseReason.TEMPORARY_NEED); + const resumed = useSubscriptionStore.getState().resumeSubscription(id, true); + + expect(resumed).not.toBeNull(); + expect(resumed?.status).toBe('resumed'); + expect(resumed?.creditRemaining).toBeGreaterThanOrEqual(0); + expect(useSubscriptionStore.getState().subscriptions[0].isActive).toBe(true); + const shifted = useSubscriptionStore.getState().subscriptions[0].nextBillingDate; + expect(shifted.getTime()).toBeGreaterThan(originalNextBillingDate.getTime()); + expect(useSubscriptionStore.getState().getPauseHistory(id)[0].status).toBe('resumed'); + }); +}); diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index 263048ec..b0e31f18 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -15,6 +15,14 @@ import { CreditPurchaseInput, CreditTransferInput, } from '../types/credit'; +import { + DEFAULT_PAUSE_LIMITS, + PauseLimits, + PauseReason, + PauseState, + PauseValidationResult, + type PauseRecord, +} from '../types/pause'; import { InvoiceStatus, isOpenInvoice } from '../types/invoice'; import { dummySubscriptions } from '../utils/dummyData'; // eslint-disable-line import { advanceBillingDate } from '../utils/billingDate'; @@ -56,7 +64,16 @@ import { applyCreditMemo, ProrationPreview, CreditMemo, + getPeriodDays, } from '../utils/proration'; +import { + calculateEarlyResumeCredit, + calculatePauseCredit, + resumePause, + validatePauseRequest, +} from './pauseStore'; + +export type { PauseRecord } from '../types/pause'; const STORAGE_KEY = 'subtrackr-subscriptions'; const STORE_VERSION = 2; @@ -72,7 +89,10 @@ const generateUniqueId = (): string => { return `${timestamp}-${randomComponent}`; }; -type PersistedSubscriptionSlice = Pick; +type PersistedSubscriptionSlice = Pick< + SubscriptionState, + 'subscriptions' | 'creditAccounts' | 'pauseHistory' +>; const toValidDate = (value: unknown, fallback = new Date()): Date => { if (value instanceof Date && !Number.isNaN(value.getTime())) return value; @@ -246,6 +266,14 @@ const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscr }, ]) ) as Record, + pauseHistory: (state.pauseHistory ?? []).map((record) => ({ + ...record, + pausedAt: new Date(record.pausedAt), + scheduledResumeAt: new Date(record.scheduledResumeAt), + resumedAt: record.resumedAt ? new Date(record.resumedAt) : undefined, + plannedResumeDate: record.plannedResumeDate ? new Date(record.plannedResumeDate) : undefined, + resumeAt: record.resumeAt ? new Date(record.resumeAt) : undefined, + })), }); const migratePersistedState = ( @@ -253,7 +281,7 @@ const migratePersistedState = ( _version: number ): PersistedSubscriptionSlice => { if (!persisted || typeof persisted !== 'object') { - return { subscriptions: [], creditAccounts: {} }; + return { subscriptions: [], creditAccounts: {}, pauseHistory: [] }; } const maybeState = persisted as Partial; @@ -272,8 +300,20 @@ const migratePersistedState = ( return acc; }, {}) : {}; + const pauseHistory = Array.isArray(maybeState.pauseHistory) + ? maybeState.pauseHistory.map((entry) => ({ + ...entry, + pausedAt: toValidDate(entry.pausedAt, new Date()), + scheduledResumeAt: toValidDate(entry.scheduledResumeAt, new Date()), + resumedAt: entry.resumedAt ? toValidDate(entry.resumedAt, new Date()) : undefined, + plannedResumeDate: entry.plannedResumeDate + ? toValidDate(entry.plannedResumeDate, new Date()) + : undefined, + resumeAt: entry.resumeAt ? toValidDate(entry.resumeAt, new Date()) : undefined, + })) + : []; - return { subscriptions, creditAccounts }; + return { subscriptions, creditAccounts, pauseHistory }; }; const pendingWrites = new Map(); @@ -324,6 +364,7 @@ const debouncedAsyncStorage: StateStorage = { interface SubscriptionState { subscriptions: Subscription[]; creditAccounts: Record; + pauseHistory: PauseRecord[]; stats: SubscriptionStats; isLoading: boolean; error: AppError | null; @@ -331,6 +372,16 @@ interface SubscriptionState { creditMemos: Record; // Actions + pauseSubscription: ( + subscriptionOrId: string | Subscription, + pauseDays: number, + reason?: PauseReason | string, + limits?: PauseLimits, + note?: string + ) => PauseRecord; + resumeSubscription: (id: string, early?: boolean) => PauseRecord | null; + getPauseHistory: (subscriptionId?: string) => PauseRecord[]; + getActivePause: (subscriptionId: string) => PauseRecord | undefined; addSubscription: (data: SubscriptionFormData) => Promise; updateSubscription: (id: string, data: Partial) => Promise; deleteSubscription: (id: string) => Promise; @@ -372,6 +423,7 @@ export const useSubscriptionStore = create()( (set, get) => ({ subscriptions: dummySubscriptions, creditAccounts: {}, + pauseHistory: [], stats: { totalActive: 0, totalMonthlySpend: 0, @@ -383,6 +435,121 @@ export const useSubscriptionStore = create()( prorationPreview: null, creditMemos: {}, + pauseSubscription: ( + subscriptionOrId, + pauseDays, + reason = PauseReason.OTHER, + limits = DEFAULT_PAUSE_LIMITS, + note + ) => { + const subscription = + typeof subscriptionOrId === 'string' + ? get().subscriptions.find((sub) => sub.id === subscriptionOrId) + : subscriptionOrId; + if (!subscription) throw new Error('Subscription not found'); + + const validation = validatePauseRequest( + subscription.id, + pauseDays, + get().pauseHistory, + limits + ) as PauseValidationResult; + if (!validation.valid) { + throw new Error(validation.reason ?? 'Pause request validation failed.'); + } + + const creditAmount = calculatePauseCredit(subscription, pauseDays); + const now = new Date(); + const scheduledResumeAt = new Date(now.getTime() + pauseDays * 24 * 60 * 60 * 1000); + const record: PauseRecord = { + id: generateUniqueId(), + subscriptionId: subscription.id, + state: PauseState.PAUSED, + reason: reason ?? PauseReason.OTHER, + note, + pausedAt: now, + scheduledResumeAt, + creditAmount, + currency: subscription.currency, + creditRemaining: creditAmount, + creditExpired: false, + creditExpiryDays: 90, + billingAdjustment: creditAmount, + plannedResumeDate: scheduledResumeAt, + status: 'active', + }; + + set((state) => ({ + pauseHistory: [...state.pauseHistory, record], + subscriptions: state.subscriptions.map((sub) => + sub.id === subscription.id ? { ...sub, isActive: false, updatedAt: now } : sub + ), + })); + get().calculateStats(); + return record; + }, + + resumeSubscription: (id, early = false) => { + const activePause = get().pauseHistory.find( + (record) => + record.subscriptionId === id && + (record.state === PauseState.PAUSED || record.status === 'active') + ); + if (!activePause) return null; + + const resumed = resumePause(activePause, early); + const now = new Date(); + const nextDays = Math.max( + 1, + Math.ceil( + (new Date(activePause.scheduledResumeAt).getTime() - new Date(activePause.pausedAt).getTime()) / + (1000 * 60 * 60 * 24) + ) + ); + const updatedRecord: PauseRecord = { + ...resumed, + resumedAt: now, + billingAdjustment: resumed.creditRemaining, + plannedResumeDate: activePause.scheduledResumeAt, + resumeAt: now, + status: 'resumed', + reason: activePause.reason, + }; + + set((state) => ({ + pauseHistory: state.pauseHistory.map((record) => + record.id === activePause.id ? updatedRecord : record + ), + subscriptions: state.subscriptions.map((sub) => { + if (sub.id !== id) return sub; + const shiftedNextBillingDate = new Date( + sub.nextBillingDate.getTime() + nextDays * 24 * 60 * 60 * 1000 + ); + return { + ...sub, + isActive: true, + nextBillingDate: shiftedNextBillingDate, + updatedAt: now, + }; + }), + })); + get().calculateStats(); + return updatedRecord; + }, + + getPauseHistory: (subscriptionId) => { + const history = get().pauseHistory; + if (!subscriptionId) return history; + return history.filter((record) => record.subscriptionId === subscriptionId); + }, + + getActivePause: (subscriptionId) => + get().pauseHistory.find( + (record) => + record.subscriptionId === subscriptionId && + (record.state === PauseState.PAUSED || record.status === 'active') + ), + previewPlanChange: ( id: string, newPrice: number, @@ -994,6 +1161,7 @@ export const useSubscriptionStore = create()( serializeForStorage({ subscriptions: state.subscriptions, creditAccounts: state.creditAccounts, + pauseHistory: state.pauseHistory, }), migrate: (persistedState, version) => migratePersistedState(persistedState, version), merge: (persistedState, currentState) => ({ @@ -1024,9 +1192,13 @@ export const useSubscriptionStore = create()( state?.creditAccounts && typeof state.creditAccounts === 'object' ? state.creditAccounts : {}; + const pauseHistory = Array.isArray((state as { pauseHistory?: PauseRecord[] } | undefined)?.pauseHistory) + ? (state as { pauseHistory?: PauseRecord[] }).pauseHistory ?? [] + : []; useSubscriptionStore.setState({ subscriptions, creditAccounts, + pauseHistory, isLoading: false, error: null, }); diff --git a/src/types/pause.ts b/src/types/pause.ts index 75824584..adb8876f 100644 --- a/src/types/pause.ts +++ b/src/types/pause.ts @@ -30,7 +30,7 @@ export interface PauseRecord { id: string; subscriptionId: string; state: PauseState; - reason: PauseReason; + reason: PauseReason | string; /** User-supplied note */ note?: string; pausedAt: Date; @@ -38,6 +38,12 @@ export interface PauseRecord { scheduledResumeAt: Date; /** Actual resume date (set on early/automatic resume) */ resumedAt?: Date; + /** Alias used by the UI to display the same value as the billing adjustment */ + billingAdjustment?: number; + /** Alias used by the UI for the planned resume time */ + plannedResumeDate?: Date; + /** Alias used by the UI and the billing service for active/resumed lifecycle state */ + status?: 'active' | 'resumed'; /** Prorated credit issued for unused period, in subscription currency */ creditAmount: number; currency: string; @@ -47,6 +53,8 @@ export interface PauseRecord { creditExpired: boolean; /** Days credit expires after resume (if subscription is cancelled after pause) */ creditExpiryDays: number; + /** Legacy field used by older UI code */ + resumeAt?: Date; } export interface PauseValidationResult {