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
143 changes: 48 additions & 95 deletions src/store/subscriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
import { dummySubscriptions } from '../utils/dummyData'; // eslint-disable-line
import { advanceBillingDate } from '../utils/billingDate';
import { buildBillingPeriod } from '../utils/invoice';
import { BILLING_CONVERSIONS, CACHE_CONSTANTS } from '../utils/constants/values';
import { CACHE_CONSTANTS } from '../utils/constants/values';
import { calculateSubscriptionStats } from '../utils/stats';
import {
syncRenewalReminders,
presentChargeSuccessNotification,
Expand Down Expand Up @@ -169,8 +170,16 @@ interface SubscriptionState {
deleteSubscription: (id: string) => Promise<void>;
toggleSubscriptionStatus: (id: string) => Promise<void>;
// new actions added
previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => ProrationPreview;
executePlanChange: (id: string, newPlanData: Partial<Subscription>, effectiveDate: 'immediate' | 'end_of_period') => Promise<void>;
previewPlanChange: (
id: string,
newPrice: number,
effectiveDate: 'immediate' | 'end_of_period'
) => ProrationPreview;
executePlanChange: (
id: string,
newPlanData: Partial<Subscription>,
effectiveDate: 'immediate' | 'end_of_period'
) => Promise<void>;
applyCreditToSubscription: (id: string) => Promise<void>;
/** Simulate or record a billing result (fires local notifications when enabled for this sub). */
recordBillingOutcome: (id: string, outcome: 'success' | 'failed') => Promise<void>;
Expand All @@ -187,58 +196,67 @@ export const useSubscriptionStore = create<SubscriptionState>()(
totalMonthlySpend: 0,
totalYearlySpend: 0,
categoryBreakdown: {} as Record<string, number>,
prorationPreview: null,
},
prorationPreview: null,
creditMemos: {},

previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => {

previewPlanChange: (
id: string,
newPrice: number,
effectiveDate: 'immediate' | 'end_of_period'
) => {
const sub = get().subscriptions.find((s) => s.id === id);
if (!sub) {
throw new Error('Subscription not found');
}
}

const preview = previewProration(sub, newPrice, effectiveDate);
const preview = previewProration(sub, newPrice, effectiveDate);
set({ prorationPreview: preview });
return preview;
},

executePlanChange: async (id: string, newPlanData: Partial<Subscription>, effectiveDate: 'immediate' | 'end_of_period') => {
executePlanChange: async (
id: string,
newPlanData: Partial<Subscription>,
effectiveDate: 'immediate' | 'end_of_period'
) => {
set({ isLoading: true, error: null });
try {
const sub = get().subscriptions.find((s) => s.id === id);
if (!sub) throw new Error('Subscription not found');

const preview = previewProration(sub, newPlanData.price ?? sub.price, effectiveDate);

// Generate credit memo if downgrade
let updatedCreditMemos = { ...get().creditMemos };
if (preview.isCredit && preview.amount > 0) {
const memo = generateCreditMemo(id, preview.amount, preview.description);
updatedCreditMemos[id] = memo;
}

// Update subscription
const updates: Partial<Subscription> = {
...newPlanData,
updatedAt: new Date(),
};

if (effectiveDate === 'immediate') {
// Reset billing cycle
updates.nextBillingDate = advanceBillingDate(new Date(), newPlanData.billingCycle ?? sub.billingCycle);
updates.nextBillingDate = advanceBillingDate(
new Date(),
newPlanData.billingCycle ?? sub.billingCycle
);
}

set((state) => ({
subscriptions: state.subscriptions.map((s) =>
s.id === id ? { ...s, ...updates } : s
),
subscriptions: state.subscriptions.map((s) => (s.id === id ? { ...s, ...updates } : s)),
creditMemos: updatedCreditMemos,
prorationPreview: null,
isLoading: false,
}));

get().calculateStats();
await syncRenewalReminders(get().subscriptions);

} catch (error) {
const appError = errorHandler.handleError(error as Error, {
action: 'executePlanChange',
Expand All @@ -247,28 +265,25 @@ export const useSubscriptionStore = create<SubscriptionState>()(
set({ error: appError, isLoading: false });
}
},

applyCreditToSubscription: async (id: string) => {
const sub = get().subscriptions.find((s) => s.id === id);
const memo = get().creditMemos[id];
if (!sub || !memo || memo.applied) return;

const { finalCharge, updatedMemo } = applyCreditMemo(sub.price, memo);

set((state) => ({
creditMemos: {
...state.creditMemos,
[id]: updatedMemo,
},
}));

// Could trigger a reduced charge here
console.log(`Applied credit: final charge ${finalCharge}`);
},
}),
// ... persist config ...
)
);

// Hydration state: keep loading true until persisted state is read.
isLoading: true,
error: null,
Expand Down Expand Up @@ -409,7 +424,8 @@ export const useSubscriptionStore = create<SubscriptionState>()(
dunningEntries[id] = {
failedAttempts: attempt,
lastFailureAt: new Date().toISOString(),
currentStage: attempt <= 3 ? 'retry' : attempt <= 5 ? 'warn' : attempt <= 7 ? 'suspend' : 'cancel',
currentStage:
attempt <= 3 ? 'retry' : attempt <= 5 ? 'warn' : attempt <= 7 ? 'suspend' : 'cancel',
};
await AsyncStorage.setItem('subtrackr-dunning-entries', JSON.stringify(dunningEntries));

Expand Down Expand Up @@ -498,76 +514,13 @@ export const useSubscriptionStore = create<SubscriptionState>()(

calculateStats: () => {
const { subscriptions } = get();

// Safety check: ensure subscriptions is an array
if (!subscriptions || !Array.isArray(subscriptions)) {
set({
stats: {
totalActive: 0,
totalMonthlySpend: 0,
totalYearlySpend: 0,
categoryBreakdown: {} as Record<SubscriptionCategory, number>,
},
});
return;
}

const activeSubs = subscriptions.filter((sub) => sub.isActive);

const { preferredCurrency, exchangeRates } = useSettingsStore.getState();
const rates = exchangeRates?.rates || {};

const totalMonthlySpend = activeSubs.reduce((total, sub) => {
const priceInPreferred = currencyService.convert(
sub.price,
sub.currency,
preferredCurrency,
rates
);
if (sub.billingCycle === 'monthly') return total + priceInPreferred;
if (sub.billingCycle === 'yearly') return total + priceInPreferred / 12;
if (sub.billingCycle === 'weekly')
return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_MONTH;
return total + priceInPreferred;
}, 0);

const totalYearlySpend = activeSubs.reduce((total, sub) => {
const priceInPreferred = currencyService.convert(
sub.price,
sub.currency,
preferredCurrency,
rates
);
if (sub.billingCycle === 'yearly') return total + priceInPreferred;
if (sub.billingCycle === 'monthly')
return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR;
if (sub.billingCycle === 'weekly')
return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_YEAR;
return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR;
}, 0);


const categoryBreakdown = activeSubs.reduce(
(acc, sub) => {
acc[sub.category] = (acc[sub.category] || 0) + 1;
return acc;
},
{} as Record<string, number>
);

const totalGasSpent = activeSubs.reduce(
(total, sub) => total + (sub.totalGasSpent || 0),
0
);

set({
stats: {
totalActive: activeSubs.length,
totalMonthlySpend,
totalYearlySpend,
categoryBreakdown,
totalGasSpent,
},
stats: calculateSubscriptionStats(subscriptions, (amount, currency) =>
currencyService.convert(amount, currency, preferredCurrency, rates)
),
});
},
}),
Expand Down
74 changes: 74 additions & 0 deletions src/utils/__tests__/stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { BillingCycle, Subscription, SubscriptionCategory } from '../../types/subscription';
import { BILLING_CONVERSIONS } from '../constants/values';
import { calculateSubscriptionStats, getMonthlySubscriptionSpend } from '../stats';

const makeSubscription = (overrides: Partial<Subscription> = {}): Subscription => ({
id: 'sub-1',
name: 'Test subscription',
category: SubscriptionCategory.STREAMING,
price: 10,
currency: 'USD',
billingCycle: BillingCycle.MONTHLY,
nextBillingDate: new Date('2026-01-01T00:00:00Z'),
isActive: true,
isCryptoEnabled: false,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
});

describe('calculateSubscriptionStats', () => {
it('returns empty stats for missing or non-array input', () => {
expect(calculateSubscriptionStats(undefined).totalActive).toBe(0);
expect(calculateSubscriptionStats(null).totalMonthlySpend).toBe(0);
});

it('calculates monthly, yearly, category, and gas totals from one shared function', () => {
const stats = calculateSubscriptionStats([
makeSubscription({
id: 'monthly',
price: 10,
billingCycle: BillingCycle.MONTHLY,
category: SubscriptionCategory.STREAMING,
totalGasSpent: 0.2,
}),
makeSubscription({
id: 'yearly',
price: 120,
billingCycle: BillingCycle.YEARLY,
category: SubscriptionCategory.SOFTWARE,
totalGasSpent: 0.3,
}),
makeSubscription({
id: 'weekly',
price: 5,
billingCycle: BillingCycle.WEEKLY,
category: SubscriptionCategory.GAMING,
totalGasSpent: 0.5,
}),
makeSubscription({ id: 'inactive', isActive: false }),
]);

expect(stats.totalActive).toBe(3);
expect(stats.totalMonthlySpend).toBe(10 + 10 + 5 * BILLING_CONVERSIONS.WEEKS_PER_MONTH);
expect(stats.totalYearlySpend).toBe(120 + 120 + 5 * BILLING_CONVERSIONS.WEEKS_PER_YEAR);
expect(stats.categoryBreakdown[SubscriptionCategory.STREAMING]).toBe(1);
expect(stats.categoryBreakdown[SubscriptionCategory.SOFTWARE]).toBe(1);
expect(stats.categoryBreakdown[SubscriptionCategory.GAMING]).toBe(1);
expect(stats.totalGasSpent).toBe(1);
});

it('applies a supplied currency converter before billing-cycle conversion', () => {
const subscription = makeSubscription({
price: 12,
currency: 'EUR',
billingCycle: BillingCycle.YEARLY,
});

const monthlySpend = getMonthlySubscriptionSpend(subscription, (amount, currency) =>
currency === 'EUR' ? amount * 2 : amount
);

expect(monthlySpend).toBe(2);
});
});
30 changes: 3 additions & 27 deletions src/utils/dummyData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription';
import { TIME_CONSTANTS, BILLING_CONVERSIONS, CACHE_CONSTANTS } from './constants/values';
import { TIME_CONSTANTS, CACHE_CONSTANTS } from './constants/values';
import { calculateSubscriptionStats } from './stats';

export const dummySubscriptions: Subscription[] = [
{
Expand Down Expand Up @@ -206,30 +207,5 @@ export const getUpcomingSubscriptions = (subscriptions: Subscription[]): Subscri
};

export const getTotalMonthlySpending = (subscriptions: Subscription[]): number => {
if (!subscriptions || !Array.isArray(subscriptions)) {
return 0;
}

return subscriptions
.filter((sub) => sub.isActive)
.reduce((total, sub) => {
let monthlyAmount = sub.price;

switch (sub.billingCycle) {
case BillingCycle.YEARLY:
monthlyAmount = sub.price / 12;
break;
case BillingCycle.WEEKLY:
monthlyAmount = sub.price * BILLING_CONVERSIONS.WEEKS_PER_MONTH; // Average weeks per month
break;
case BillingCycle.CUSTOM:
// For custom cycles, assume monthly for now
monthlyAmount = sub.price;
break;
default:
monthlyAmount = sub.price;
}

return total + monthlyAmount;
}, 0);
return calculateSubscriptionStats(subscriptions).totalMonthlySpend;
};
Loading