Skip to content
Open
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
129 changes: 129 additions & 0 deletions src/store/__tests__/subscriptionPauseResume.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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');
});
});
178 changes: 175 additions & 3 deletions src/store/subscriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -72,7 +89,10 @@ const generateUniqueId = (): string => {
return `${timestamp}-${randomComponent}`;
};

type PersistedSubscriptionSlice = Pick<SubscriptionState, 'subscriptions' | 'creditAccounts'>;
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;
Expand Down Expand Up @@ -246,14 +266,22 @@ const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscr
},
])
) as Record<string, CreditAccountState>,
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 = (
persisted: unknown,
_version: number
): PersistedSubscriptionSlice => {
if (!persisted || typeof persisted !== 'object') {
return { subscriptions: [], creditAccounts: {} };
return { subscriptions: [], creditAccounts: {}, pauseHistory: [] };
}

const maybeState = persisted as Partial<PersistedSubscriptionSlice>;
Expand All @@ -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<string, string>();
Expand Down Expand Up @@ -324,13 +364,24 @@ const debouncedAsyncStorage: StateStorage = {
interface SubscriptionState {
subscriptions: Subscription[];
creditAccounts: Record<string, CreditAccountState>;
pauseHistory: PauseRecord[];
stats: SubscriptionStats;
isLoading: boolean;
error: AppError | null;
prorationPreview: ProrationPreview | null;
creditMemos: Record<string, CreditMemo>;

// 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<void>;
updateSubscription: (id: string, data: Partial<Subscription>) => Promise<void>;
deleteSubscription: (id: string) => Promise<void>;
Expand Down Expand Up @@ -372,6 +423,7 @@ export const useSubscriptionStore = create<SubscriptionState>()(
(set, get) => ({
subscriptions: dummySubscriptions,
creditAccounts: {},
pauseHistory: [],
stats: {
totalActive: 0,
totalMonthlySpend: 0,
Expand All @@ -383,6 +435,121 @@ export const useSubscriptionStore = create<SubscriptionState>()(
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,
Expand Down Expand Up @@ -994,6 +1161,7 @@ export const useSubscriptionStore = create<SubscriptionState>()(
serializeForStorage({
subscriptions: state.subscriptions,
creditAccounts: state.creditAccounts,
pauseHistory: state.pauseHistory,
}),
migrate: (persistedState, version) => migratePersistedState(persistedState, version),
merge: (persistedState, currentState) => ({
Expand Down Expand Up @@ -1024,9 +1192,13 @@ export const useSubscriptionStore = create<SubscriptionState>()(
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,
});
Expand Down
Loading
Loading