Skip to content

Commit a3adc8a

Browse files
authored
Merge pull request #1071 from eischideraa-unn/feature/subscription-pause-resume-billing-adjustment
feat: add pause resume billing adjustment
2 parents feb0c0f + c9308e7 commit a3adc8a

3 files changed

Lines changed: 313 additions & 4 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
2+
import { useSubscriptionStore } from '../subscriptionStore';
3+
import { BillingCycle, SubscriptionCategory } from '../../types/subscription';
4+
import { PauseReason, PauseState } from '../../types/pause';
5+
6+
jest.mock('@react-native-async-storage/async-storage', () => {
7+
const store = new Map<string, string>();
8+
return {
9+
setItem: jest.fn((key: string, value: string) => {
10+
store.set(key, value);
11+
return Promise.resolve();
12+
}),
13+
getItem: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
14+
removeItem: jest.fn((key: string) => {
15+
store.delete(key);
16+
return Promise.resolve();
17+
}),
18+
clear: jest.fn(() => {
19+
store.clear();
20+
return Promise.resolve();
21+
}),
22+
};
23+
});
24+
25+
jest.mock('../../services/notificationService', () => ({
26+
syncRenewalReminders: jest.fn(() => Promise.resolve()),
27+
presentChargeSuccessNotification: jest.fn(() => Promise.resolve()),
28+
presentChargeFailedNotification: jest.fn(() => Promise.resolve()),
29+
presentLocalNotification: jest.fn(() => Promise.resolve()),
30+
presentDunningRetryNotification: jest.fn(() => Promise.resolve()),
31+
presentDunningWarningNotification: jest.fn(() => Promise.resolve()),
32+
presentDunningSuspendedNotification: jest.fn(() => Promise.resolve()),
33+
presentDunningCancelledNotification: jest.fn(() => Promise.resolve()),
34+
presentDunningRecoveryNotification: jest.fn(() => Promise.resolve()),
35+
}));
36+
37+
describe('subscription pause/resume billing flow', () => {
38+
beforeEach(() => {
39+
useSubscriptionStore.setState({
40+
subscriptions: [],
41+
creditAccounts: {},
42+
pauseHistory: [],
43+
stats: {
44+
totalActive: 0,
45+
totalMonthlySpend: 0,
46+
totalYearlySpend: 0,
47+
categoryBreakdown: {
48+
[SubscriptionCategory.STREAMING]: 0,
49+
[SubscriptionCategory.SOFTWARE]: 0,
50+
[SubscriptionCategory.GAMING]: 0,
51+
[SubscriptionCategory.PRODUCTIVITY]: 0,
52+
[SubscriptionCategory.FITNESS]: 0,
53+
[SubscriptionCategory.EDUCATION]: 0,
54+
[SubscriptionCategory.FINANCE]: 0,
55+
[SubscriptionCategory.OTHER]: 0,
56+
},
57+
},
58+
isLoading: false,
59+
error: null,
60+
prorationPreview: null,
61+
creditMemos: {},
62+
});
63+
});
64+
65+
it('creates a pause record with prorated credit and marks the subscription inactive', () => {
66+
const id = 'sub-1';
67+
useSubscriptionStore.setState({
68+
subscriptions: [
69+
{
70+
id,
71+
name: 'Netflix',
72+
category: SubscriptionCategory.STREAMING,
73+
price: 30,
74+
currency: 'USD',
75+
billingCycle: BillingCycle.MONTHLY,
76+
nextBillingDate: new Date('2026-08-30T00:00:00.000Z'),
77+
isActive: true,
78+
notificationsEnabled: true,
79+
isCryptoEnabled: false,
80+
createdAt: new Date('2026-08-01T00:00:00.000Z'),
81+
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
82+
},
83+
],
84+
});
85+
86+
const record = useSubscriptionStore.getState().pauseSubscription(id, 14, PauseReason.VACATION);
87+
88+
expect(record.state).toBe(PauseState.PAUSED);
89+
expect(record.creditAmount).toBe(14);
90+
expect(record.creditRemaining).toBe(14);
91+
expect(record.status).toBe('active');
92+
expect(useSubscriptionStore.getState().subscriptions[0].isActive).toBe(false);
93+
expect(useSubscriptionStore.getState().getActivePause(id)?.subscriptionId).toBe(id);
94+
});
95+
96+
it('resumes a paused subscription and shifts the next billing date', () => {
97+
const id = 'sub-2';
98+
const originalNextBillingDate = new Date('2026-08-30T00:00:00.000Z');
99+
useSubscriptionStore.setState({
100+
subscriptions: [
101+
{
102+
id,
103+
name: 'Spotify',
104+
category: SubscriptionCategory.OTHER,
105+
price: 60,
106+
currency: 'USD',
107+
billingCycle: BillingCycle.MONTHLY,
108+
nextBillingDate: originalNextBillingDate,
109+
isActive: true,
110+
notificationsEnabled: true,
111+
isCryptoEnabled: false,
112+
createdAt: new Date('2026-08-01T00:00:00.000Z'),
113+
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
114+
},
115+
],
116+
});
117+
118+
useSubscriptionStore.getState().pauseSubscription(id, 14, PauseReason.TEMPORARY_NEED);
119+
const resumed = useSubscriptionStore.getState().resumeSubscription(id, true);
120+
121+
expect(resumed).not.toBeNull();
122+
expect(resumed?.status).toBe('resumed');
123+
expect(resumed?.creditRemaining).toBeGreaterThanOrEqual(0);
124+
expect(useSubscriptionStore.getState().subscriptions[0].isActive).toBe(true);
125+
const shifted = useSubscriptionStore.getState().subscriptions[0].nextBillingDate;
126+
expect(shifted.getTime()).toBeGreaterThan(originalNextBillingDate.getTime());
127+
expect(useSubscriptionStore.getState().getPauseHistory(id)[0].status).toBe('resumed');
128+
});
129+
});

src/store/subscriptionStore.ts

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import {
1515
CreditPurchaseInput,
1616
CreditTransferInput,
1717
} from '../types/credit';
18+
import {
19+
DEFAULT_PAUSE_LIMITS,
20+
PauseLimits,
21+
PauseReason,
22+
PauseState,
23+
PauseValidationResult,
24+
type PauseRecord,
25+
} from '../types/pause';
1826
import { InvoiceStatus, isOpenInvoice } from '../types/invoice';
1927
import { dummySubscriptions } from '../utils/dummyData'; // eslint-disable-line
2028
import { advanceBillingDate } from '../utils/billingDate';
@@ -56,7 +64,16 @@ import {
5664
applyCreditMemo,
5765
ProrationPreview,
5866
CreditMemo,
67+
getPeriodDays,
5968
} from '../utils/proration';
69+
import {
70+
calculateEarlyResumeCredit,
71+
calculatePauseCredit,
72+
resumePause,
73+
validatePauseRequest,
74+
} from './pauseStore';
75+
76+
export type { PauseRecord } from '../types/pause';
6077

6178
const STORAGE_KEY = 'subtrackr-subscriptions';
6279
const STORE_VERSION = 2;
@@ -72,7 +89,10 @@ const generateUniqueId = (): string => {
7289
return `${timestamp}-${randomComponent}`;
7390
};
7491

75-
type PersistedSubscriptionSlice = Pick<SubscriptionState, 'subscriptions' | 'creditAccounts'>;
92+
type PersistedSubscriptionSlice = Pick<
93+
SubscriptionState,
94+
'subscriptions' | 'creditAccounts' | 'pauseHistory'
95+
>;
7696

7797
const toValidDate = (value: unknown, fallback = new Date()): Date => {
7898
if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
@@ -246,14 +266,22 @@ const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscr
246266
},
247267
])
248268
) as Record<string, CreditAccountState>,
269+
pauseHistory: (state.pauseHistory ?? []).map((record) => ({
270+
...record,
271+
pausedAt: new Date(record.pausedAt),
272+
scheduledResumeAt: new Date(record.scheduledResumeAt),
273+
resumedAt: record.resumedAt ? new Date(record.resumedAt) : undefined,
274+
plannedResumeDate: record.plannedResumeDate ? new Date(record.plannedResumeDate) : undefined,
275+
resumeAt: record.resumeAt ? new Date(record.resumeAt) : undefined,
276+
})),
249277
});
250278

251279
const migratePersistedState = (
252280
persisted: unknown,
253281
_version: number
254282
): PersistedSubscriptionSlice => {
255283
if (!persisted || typeof persisted !== 'object') {
256-
return { subscriptions: [], creditAccounts: {} };
284+
return { subscriptions: [], creditAccounts: {}, pauseHistory: [] };
257285
}
258286

259287
const maybeState = persisted as Partial<PersistedSubscriptionSlice>;
@@ -272,8 +300,20 @@ const migratePersistedState = (
272300
return acc;
273301
}, {})
274302
: {};
303+
const pauseHistory = Array.isArray(maybeState.pauseHistory)
304+
? maybeState.pauseHistory.map((entry) => ({
305+
...entry,
306+
pausedAt: toValidDate(entry.pausedAt, new Date()),
307+
scheduledResumeAt: toValidDate(entry.scheduledResumeAt, new Date()),
308+
resumedAt: entry.resumedAt ? toValidDate(entry.resumedAt, new Date()) : undefined,
309+
plannedResumeDate: entry.plannedResumeDate
310+
? toValidDate(entry.plannedResumeDate, new Date())
311+
: undefined,
312+
resumeAt: entry.resumeAt ? toValidDate(entry.resumeAt, new Date()) : undefined,
313+
}))
314+
: [];
275315

276-
return { subscriptions, creditAccounts };
316+
return { subscriptions, creditAccounts, pauseHistory };
277317
};
278318

279319
const pendingWrites = new Map<string, string>();
@@ -324,13 +364,24 @@ const debouncedAsyncStorage: StateStorage = {
324364
interface SubscriptionState {
325365
subscriptions: Subscription[];
326366
creditAccounts: Record<string, CreditAccountState>;
367+
pauseHistory: PauseRecord[];
327368
stats: SubscriptionStats;
328369
isLoading: boolean;
329370
error: AppError | null;
330371
prorationPreview: ProrationPreview | null;
331372
creditMemos: Record<string, CreditMemo>;
332373

333374
// Actions
375+
pauseSubscription: (
376+
subscriptionOrId: string | Subscription,
377+
pauseDays: number,
378+
reason?: PauseReason | string,
379+
limits?: PauseLimits,
380+
note?: string
381+
) => PauseRecord;
382+
resumeSubscription: (id: string, early?: boolean) => PauseRecord | null;
383+
getPauseHistory: (subscriptionId?: string) => PauseRecord[];
384+
getActivePause: (subscriptionId: string) => PauseRecord | undefined;
334385
addSubscription: (data: SubscriptionFormData) => Promise<void>;
335386
updateSubscription: (id: string, data: Partial<Subscription>) => Promise<void>;
336387
deleteSubscription: (id: string) => Promise<void>;
@@ -372,6 +423,7 @@ export const useSubscriptionStore = create<SubscriptionState>()(
372423
(set, get) => ({
373424
subscriptions: dummySubscriptions,
374425
creditAccounts: {},
426+
pauseHistory: [],
375427
stats: {
376428
totalActive: 0,
377429
totalMonthlySpend: 0,
@@ -383,6 +435,121 @@ export const useSubscriptionStore = create<SubscriptionState>()(
383435
prorationPreview: null,
384436
creditMemos: {},
385437

438+
pauseSubscription: (
439+
subscriptionOrId,
440+
pauseDays,
441+
reason = PauseReason.OTHER,
442+
limits = DEFAULT_PAUSE_LIMITS,
443+
note
444+
) => {
445+
const subscription =
446+
typeof subscriptionOrId === 'string'
447+
? get().subscriptions.find((sub) => sub.id === subscriptionOrId)
448+
: subscriptionOrId;
449+
if (!subscription) throw new Error('Subscription not found');
450+
451+
const validation = validatePauseRequest(
452+
subscription.id,
453+
pauseDays,
454+
get().pauseHistory,
455+
limits
456+
) as PauseValidationResult;
457+
if (!validation.valid) {
458+
throw new Error(validation.reason ?? 'Pause request validation failed.');
459+
}
460+
461+
const creditAmount = calculatePauseCredit(subscription, pauseDays);
462+
const now = new Date();
463+
const scheduledResumeAt = new Date(now.getTime() + pauseDays * 24 * 60 * 60 * 1000);
464+
const record: PauseRecord = {
465+
id: generateUniqueId(),
466+
subscriptionId: subscription.id,
467+
state: PauseState.PAUSED,
468+
reason: reason ?? PauseReason.OTHER,
469+
note,
470+
pausedAt: now,
471+
scheduledResumeAt,
472+
creditAmount,
473+
currency: subscription.currency,
474+
creditRemaining: creditAmount,
475+
creditExpired: false,
476+
creditExpiryDays: 90,
477+
billingAdjustment: creditAmount,
478+
plannedResumeDate: scheduledResumeAt,
479+
status: 'active',
480+
};
481+
482+
set((state) => ({
483+
pauseHistory: [...state.pauseHistory, record],
484+
subscriptions: state.subscriptions.map((sub) =>
485+
sub.id === subscription.id ? { ...sub, isActive: false, updatedAt: now } : sub
486+
),
487+
}));
488+
get().calculateStats();
489+
return record;
490+
},
491+
492+
resumeSubscription: (id, early = false) => {
493+
const activePause = get().pauseHistory.find(
494+
(record) =>
495+
record.subscriptionId === id &&
496+
(record.state === PauseState.PAUSED || record.status === 'active')
497+
);
498+
if (!activePause) return null;
499+
500+
const resumed = resumePause(activePause, early);
501+
const now = new Date();
502+
const nextDays = Math.max(
503+
1,
504+
Math.ceil(
505+
(new Date(activePause.scheduledResumeAt).getTime() - new Date(activePause.pausedAt).getTime()) /
506+
(1000 * 60 * 60 * 24)
507+
)
508+
);
509+
const updatedRecord: PauseRecord = {
510+
...resumed,
511+
resumedAt: now,
512+
billingAdjustment: resumed.creditRemaining,
513+
plannedResumeDate: activePause.scheduledResumeAt,
514+
resumeAt: now,
515+
status: 'resumed',
516+
reason: activePause.reason,
517+
};
518+
519+
set((state) => ({
520+
pauseHistory: state.pauseHistory.map((record) =>
521+
record.id === activePause.id ? updatedRecord : record
522+
),
523+
subscriptions: state.subscriptions.map((sub) => {
524+
if (sub.id !== id) return sub;
525+
const shiftedNextBillingDate = new Date(
526+
sub.nextBillingDate.getTime() + nextDays * 24 * 60 * 60 * 1000
527+
);
528+
return {
529+
...sub,
530+
isActive: true,
531+
nextBillingDate: shiftedNextBillingDate,
532+
updatedAt: now,
533+
};
534+
}),
535+
}));
536+
get().calculateStats();
537+
return updatedRecord;
538+
},
539+
540+
getPauseHistory: (subscriptionId) => {
541+
const history = get().pauseHistory;
542+
if (!subscriptionId) return history;
543+
return history.filter((record) => record.subscriptionId === subscriptionId);
544+
},
545+
546+
getActivePause: (subscriptionId) =>
547+
get().pauseHistory.find(
548+
(record) =>
549+
record.subscriptionId === subscriptionId &&
550+
(record.state === PauseState.PAUSED || record.status === 'active')
551+
),
552+
386553
previewPlanChange: (
387554
id: string,
388555
newPrice: number,
@@ -994,6 +1161,7 @@ export const useSubscriptionStore = create<SubscriptionState>()(
9941161
serializeForStorage({
9951162
subscriptions: state.subscriptions,
9961163
creditAccounts: state.creditAccounts,
1164+
pauseHistory: state.pauseHistory,
9971165
}),
9981166
migrate: (persistedState, version) => migratePersistedState(persistedState, version),
9991167
merge: (persistedState, currentState) => ({
@@ -1024,9 +1192,13 @@ export const useSubscriptionStore = create<SubscriptionState>()(
10241192
state?.creditAccounts && typeof state.creditAccounts === 'object'
10251193
? state.creditAccounts
10261194
: {};
1195+
const pauseHistory = Array.isArray((state as { pauseHistory?: PauseRecord[] } | undefined)?.pauseHistory)
1196+
? (state as { pauseHistory?: PauseRecord[] }).pauseHistory ?? []
1197+
: [];
10271198
useSubscriptionStore.setState({
10281199
subscriptions,
10291200
creditAccounts,
1201+
pauseHistory,
10301202
isLoading: false,
10311203
error: null,
10321204
});

0 commit comments

Comments
 (0)