Skip to content

Commit b6f4b05

Browse files
arkjunclaude
andcommitted
fix: correct yearly subscription amount calculation
- Weekly subscriptions now use 52 weeks/year (was 48) - Add normalizeToYearly() for accurate yearly calculations - Add calculateYearlyTotal() to compute yearly totals directly - Update normalizeToMonthly() weekly factor from 4 to 52/12 (~4.33) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent dff783a commit b6f4b05

File tree

3 files changed

+143
-5
lines changed

3 files changed

+143
-5
lines changed

apps/web/src/components/subscription/subscription-list.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { useAuth } from '@/components/auth/auth-provider';
88
import { Button } from '@/components/ui/button';
99
import { DataTable } from '@/components/ui/data-table';
1010
import { useRouter } from '@/i18n/navigation';
11-
import { CURRENCY_SYMBOLS, calculateMonthlyTotal } from '@/lib/currency';
11+
import {
12+
CURRENCY_SYMBOLS,
13+
calculateMonthlyTotal,
14+
calculateYearlyTotal,
15+
} from '@/lib/currency';
1216
import { getColumns } from './columns';
1317
import { SubscriptionCard } from './subscription-card';
1418
import { SubscriptionForm } from './subscription-form';
@@ -144,7 +148,10 @@ export function SubscriptionList() {
144148
[subscriptions, userCurrency],
145149
);
146150

147-
const yearlyTotal = monthlyTotal * 12;
151+
const { total: yearlyTotal } = useMemo(
152+
() => calculateYearlyTotal(subscriptions, userCurrency),
153+
[subscriptions, userCurrency],
154+
);
148155
const activeCount = subscriptions.filter((s) => s.isActive).length;
149156

150157
if (authLoading || loading) {

apps/web/src/lib/currency.test.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, expect, it } from 'vitest';
22
import {
33
calculateMonthlyTotal,
4+
calculateYearlyTotal,
45
convertCurrency,
56
formatPrice,
67
normalizeToMonthly,
8+
normalizeToYearly,
79
type SubscriptionForCalculation,
810
} from './currency';
911

@@ -44,8 +46,9 @@ describe('normalizeToMonthly', () => {
4446
expect(normalizeToMonthly(12000, 'yearly')).toBe(1000);
4547
});
4648

47-
it('multiplies weekly price by 4', () => {
48-
expect(normalizeToMonthly(2500, 'weekly')).toBe(10000);
49+
it('multiplies weekly price by 52/12 for accurate monthly estimate', () => {
50+
// 2500 * (52/12) ≈ 10833.33
51+
expect(normalizeToMonthly(2500, 'weekly')).toBeCloseTo(10833.33, 1);
4952
});
5053

5154
it('divides quarterly price by 3', () => {
@@ -192,3 +195,101 @@ describe('calculateMonthlyTotal', () => {
192195
expect(result.hasMixedCurrencies).toBe(true);
193196
});
194197
});
198+
199+
describe('normalizeToYearly', () => {
200+
it('returns same price for yearly billing', () => {
201+
expect(normalizeToYearly(120000, 'yearly')).toBe(120000);
202+
});
203+
204+
it('multiplies monthly price by 12', () => {
205+
expect(normalizeToYearly(10000, 'monthly')).toBe(120000);
206+
});
207+
208+
it('multiplies weekly price by 52', () => {
209+
expect(normalizeToYearly(1000, 'weekly')).toBe(52000);
210+
});
211+
212+
it('multiplies quarterly price by 4', () => {
213+
expect(normalizeToYearly(30000, 'quarterly')).toBe(120000);
214+
});
215+
});
216+
217+
describe('calculateYearlyTotal', () => {
218+
it('returns 0 for empty subscriptions', () => {
219+
const result = calculateYearlyTotal([], 'KRW');
220+
expect(result.total).toBe(0);
221+
expect(result.hasMixedCurrencies).toBe(false);
222+
});
223+
224+
it('calculates yearly total for monthly subscription', () => {
225+
const subscriptions: SubscriptionForCalculation[] = [
226+
{
227+
price: 10000,
228+
currency: 'KRW',
229+
billingCycle: 'monthly',
230+
isActive: true,
231+
},
232+
];
233+
// ₩10,000/month * 12 = ₩120,000/year
234+
const result = calculateYearlyTotal(subscriptions, 'KRW');
235+
expect(result.total).toBe(120000);
236+
});
237+
238+
it('calculates yearly total for weekly subscription (52 weeks)', () => {
239+
const subscriptions: SubscriptionForCalculation[] = [
240+
{ price: 1000, currency: 'KRW', billingCycle: 'weekly', isActive: true },
241+
];
242+
// ₩1,000/week * 52 = ₩52,000/year
243+
const result = calculateYearlyTotal(subscriptions, 'KRW');
244+
expect(result.total).toBe(52000);
245+
});
246+
247+
it('returns same amount for yearly subscription', () => {
248+
const subscriptions: SubscriptionForCalculation[] = [
249+
{
250+
price: 120000,
251+
currency: 'KRW',
252+
billingCycle: 'yearly',
253+
isActive: true,
254+
},
255+
];
256+
const result = calculateYearlyTotal(subscriptions, 'KRW');
257+
expect(result.total).toBe(120000);
258+
});
259+
260+
it('calculates yearly total for quarterly subscription', () => {
261+
const subscriptions: SubscriptionForCalculation[] = [
262+
{
263+
price: 30000,
264+
currency: 'KRW',
265+
billingCycle: 'quarterly',
266+
isActive: true,
267+
},
268+
];
269+
// ₩30,000/quarter * 4 = ₩120,000/year
270+
const result = calculateYearlyTotal(subscriptions, 'KRW');
271+
expect(result.total).toBe(120000);
272+
});
273+
274+
it('handles complex scenario with mixed currencies and billing cycles', () => {
275+
const subscriptions: SubscriptionForCalculation[] = [
276+
{
277+
price: 10000,
278+
currency: 'KRW',
279+
billingCycle: 'monthly',
280+
isActive: true,
281+
},
282+
{ price: 120, currency: 'USD', billingCycle: 'yearly', isActive: true },
283+
{ price: 30, currency: 'EUR', billingCycle: 'quarterly', isActive: true },
284+
{ price: 500, currency: 'JPY', billingCycle: 'weekly', isActive: false },
285+
];
286+
// KRW: ₩10,000/month * 12 = ₩120,000/year
287+
// USD: $120/year = ₩168,000/year
288+
// EUR: €30/quarter * 4 = €120/year = ₩180,000/year
289+
// JPY: inactive, ignored
290+
// Total: ₩468,000
291+
const result = calculateYearlyTotal(subscriptions, 'KRW');
292+
expect(result.total).toBe(468000);
293+
expect(result.hasMixedCurrencies).toBe(true);
294+
});
295+
});

apps/web/src/lib/currency.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,27 @@ export function normalizeToMonthly(price: number, cycle: BillingCycle): number {
3535
case 'yearly':
3636
return price / 12;
3737
case 'weekly':
38-
return price * 4;
38+
return price * (52 / 12); // 연간 52주 기준 월간 환산 (~4.33주/월)
3939
case 'quarterly':
4040
return price / 3;
4141
default:
4242
return price;
4343
}
4444
}
4545

46+
export function normalizeToYearly(price: number, cycle: BillingCycle): number {
47+
switch (cycle) {
48+
case 'monthly':
49+
return price * 12;
50+
case 'weekly':
51+
return price * 52;
52+
case 'quarterly':
53+
return price * 4;
54+
default:
55+
return price; // yearly
56+
}
57+
}
58+
4659
export interface SubscriptionForCalculation {
4760
price: number;
4861
currency: Currency;
@@ -66,3 +79,20 @@ export function calculateMonthlyTotal(
6679

6780
return { total, hasMixedCurrencies };
6881
}
82+
83+
export function calculateYearlyTotal(
84+
subscriptions: SubscriptionForCalculation[],
85+
targetCurrency: Currency,
86+
): { total: number; hasMixedCurrencies: boolean } {
87+
const activeSubscriptions = subscriptions.filter((s) => s.isActive);
88+
const currencies = new Set(activeSubscriptions.map((s) => s.currency));
89+
const hasMixedCurrencies = currencies.size > 1;
90+
91+
const total = activeSubscriptions.reduce((sum, s) => {
92+
const yearlyPrice = normalizeToYearly(s.price, s.billingCycle);
93+
const converted = convertCurrency(yearlyPrice, s.currency, targetCurrency);
94+
return sum + converted;
95+
}, 0);
96+
97+
return { total, hasMixedCurrencies };
98+
}

0 commit comments

Comments
 (0)