From 077b0b14e7b2c442772a103eab9efe9f2266aba9 Mon Sep 17 00:00:00 2001 From: er1c-cartman <176489063+er1c-cartman@users.noreply.github.com> Date: Fri, 29 May 2026 15:08:49 +0900 Subject: [PATCH] Share subscription stats calculation --- src/store/subscriptionStore.ts | 143 ++++++++++-------------------- src/utils/__tests__/stats.test.ts | 74 ++++++++++++++++ src/utils/dummyData.ts | 30 +------ src/utils/stats.ts | 64 +++++++++++++ 4 files changed, 189 insertions(+), 122 deletions(-) create mode 100644 src/utils/__tests__/stats.test.ts create mode 100644 src/utils/stats.ts diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index cd6bb4a..29a4b8d 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -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, @@ -169,8 +170,16 @@ interface SubscriptionState { deleteSubscription: (id: string) => Promise; toggleSubscriptionStatus: (id: string) => Promise; // new actions added - previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => ProrationPreview; - executePlanChange: (id: string, newPlanData: Partial, effectiveDate: 'immediate' | 'end_of_period') => Promise; + previewPlanChange: ( + id: string, + newPrice: number, + effectiveDate: 'immediate' | 'end_of_period' + ) => ProrationPreview; + executePlanChange: ( + id: string, + newPlanData: Partial, + effectiveDate: 'immediate' | 'end_of_period' + ) => Promise; applyCreditToSubscription: (id: string) => Promise; /** Simulate or record a billing result (fires local notifications when enabled for this sub). */ recordBillingOutcome: (id: string, outcome: 'success' | 'failed') => Promise; @@ -187,58 +196,67 @@ export const useSubscriptionStore = create()( totalMonthlySpend: 0, totalYearlySpend: 0, categoryBreakdown: {} as Record, - 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, effectiveDate: 'immediate' | 'end_of_period') => { + executePlanChange: async ( + id: string, + newPlanData: Partial, + 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 = { ...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', @@ -247,28 +265,25 @@ export const useSubscriptionStore = create()( 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, @@ -409,7 +424,8 @@ export const useSubscriptionStore = create()( 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)); @@ -498,76 +514,13 @@ export const useSubscriptionStore = create()( 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, - }, - }); - 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 - ); - - 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) + ), }); }, }), diff --git a/src/utils/__tests__/stats.test.ts b/src/utils/__tests__/stats.test.ts new file mode 100644 index 0000000..d0caa9e --- /dev/null +++ b/src/utils/__tests__/stats.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/utils/dummyData.ts b/src/utils/dummyData.ts index 3d8aae4..f151b27 100644 --- a/src/utils/dummyData.ts +++ b/src/utils/dummyData.ts @@ -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[] = [ { @@ -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; }; diff --git a/src/utils/stats.ts b/src/utils/stats.ts new file mode 100644 index 0000000..cb904c5 --- /dev/null +++ b/src/utils/stats.ts @@ -0,0 +1,64 @@ +import { + BillingCycle, + Subscription, + SubscriptionCategory, + SubscriptionStats, +} from '../types/subscription'; +import { BILLING_CONVERSIONS } from './constants/values'; + +type PriceConverter = (amount: number, currency: string) => number; + +const identityConverter: PriceConverter = (amount) => amount; + +export const emptySubscriptionStats = (): SubscriptionStats => ({ + totalActive: 0, + totalMonthlySpend: 0, + totalYearlySpend: 0, + categoryBreakdown: {} as Record, + totalGasSpent: 0, +}); + +export const getMonthlySubscriptionSpend = ( + subscription: Subscription, + convertPrice: PriceConverter = identityConverter +): number => { + const price = convertPrice(subscription.price, subscription.currency); + + if (subscription.billingCycle === BillingCycle.YEARLY) return price / 12; + if (subscription.billingCycle === BillingCycle.WEEKLY) + return price * BILLING_CONVERSIONS.WEEKS_PER_MONTH; + return price; +}; + +export const getYearlySubscriptionSpend = ( + subscription: Subscription, + convertPrice: PriceConverter = identityConverter +): number => { + const price = convertPrice(subscription.price, subscription.currency); + + if (subscription.billingCycle === BillingCycle.YEARLY) return price; + if (subscription.billingCycle === BillingCycle.WEEKLY) + return price * BILLING_CONVERSIONS.WEEKS_PER_YEAR; + return price * BILLING_CONVERSIONS.MONTHS_PER_YEAR; +}; + +export const calculateSubscriptionStats = ( + subscriptions: Subscription[] | null | undefined, + convertPrice: PriceConverter = identityConverter +): SubscriptionStats => { + if (!Array.isArray(subscriptions)) { + return emptySubscriptionStats(); + } + + const activeSubscriptions = subscriptions.filter((sub) => sub.isActive); + + return activeSubscriptions.reduce((stats, subscription) => { + stats.totalActive += 1; + stats.totalMonthlySpend += getMonthlySubscriptionSpend(subscription, convertPrice); + stats.totalYearlySpend += getYearlySubscriptionSpend(subscription, convertPrice); + stats.categoryBreakdown[subscription.category] = + (stats.categoryBreakdown[subscription.category] || 0) + 1; + stats.totalGasSpent = (stats.totalGasSpent || 0) + (subscription.totalGasSpent || 0); + return stats; + }, emptySubscriptionStats()); +};