diff --git a/.changeset/converge-split-meow-currency-snapshot.md b/.changeset/converge-split-meow-currency-snapshot.md new file mode 100644 index 000000000..a3d9f3626 --- /dev/null +++ b/.changeset/converge-split-meow-currency-snapshot.md @@ -0,0 +1,5 @@ +--- +'@app/split-meow': minor +--- + +每筆支出會保存記帳當下的幣別與匯率快照;歷史頁的韓元(KRW)支出會額外顯示對應的新台幣金額(≈ NT$),且日後匯率變動不影響既有紀錄的顯示。 diff --git a/apps/split-meow/src/components/HistoryTab.tsx b/apps/split-meow/src/components/HistoryTab.tsx index 22c953f27..40c284a3c 100644 --- a/apps/split-meow/src/components/HistoryTab.tsx +++ b/apps/split-meow/src/components/HistoryTab.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useStore, type Member, type ExpenseRecord } from '../store/useStore'; -import { formatAmount, getCurrencySymbol } from '../config/currencies'; +import { formatAmount, getCurrencySymbol, formatKrwAsTwd } from '../config/currencies'; +import type { CurrencyCode } from '../config/currencies'; import { format } from 'date-fns'; import { useRef, useState } from 'react'; import { cn } from '../lib/utils'; @@ -177,6 +178,9 @@ export function HistoryTab() { }); const totalSpent = tripExpenses.reduce((sum, e) => sum + e.totalAmount, 0); + const tripCurrency: CurrencyCode = tripExpenses[tripExpenses.length - 1]?.currency ?? currency; + const expenseCurrency = (exp: ExpenseRecord): CurrencyCode => exp.currency ?? tripCurrency; + // 計算各人餘額 const balances: Record = {}; tripExpenses.forEach((exp) => { @@ -215,7 +219,7 @@ export function HistoryTab() { const lines: string[] = [ `🐾 喵喵分帳 — ${tripName}`, `${'─'.repeat(24)}`, - `💰 總花費:${formatAmount(totalSpent, currency)}`, + `💰 總花費:${formatAmount(totalSpent, tripCurrency)}`, '', ]; if (tripExpenses.length > 0) { @@ -226,7 +230,9 @@ export function HistoryTab() { const label = exp.note || (exp.type === 'split_evenly' ? t('history.split_evenly') : t('history.itemized')); - lines.push(`${emoji} ${label} ${formatAmount(exp.totalAmount, currency)}(${payer} 付)`); + lines.push( + `${emoji} ${label} ${formatAmount(exp.totalAmount, expenseCurrency(exp))}(${payer} 付)`, + ); }); lines.push(''); } @@ -235,7 +241,7 @@ export function HistoryTab() { settlements.forEach((s) => { const from = members.find((m) => m.id === s.from)?.name ?? s.from; const to = members.find((m) => m.id === s.to)?.name ?? s.to; - lines.push(`${from} → ${to} ${formatAmount(s.amount, currency)}`); + lines.push(`${from} → ${to} ${formatAmount(s.amount, tripCurrency)}`); }); lines.push(''); } @@ -273,7 +279,7 @@ export function HistoryTab() { {t('history.total_spent')}

- {formatAmount(totalSpent, currency)} + {formatAmount(totalSpent, tripCurrency)}

@@ -376,7 +382,7 @@ export function HistoryTab() { check_circle ) : ( - formatAmount(s.amount, currency) + formatAmount(s.amount, tripCurrency) )}
@@ -451,7 +457,7 @@ export function HistoryTab() { )} > {isOwed ? '+' : '-'} - {formatAmount(Math.abs(amount), currency)} + {formatAmount(Math.abs(amount), tripCurrency)} - +{formatAmount(exp.totalAmount, currency)} + +{formatAmount(exp.totalAmount, expenseCurrency(exp))} )} @@ -528,7 +534,7 @@ export function HistoryTab() { {t('history.balance_share')} - -{formatAmount(share, currency)} + -{formatAmount(share, expenseCurrency(exp))} )} @@ -549,7 +555,7 @@ export function HistoryTab() { )} > {net > 0.01 ? '+' : net < -0.01 ? '-' : ''} - {formatAmount(Math.abs(net), currency)} + {formatAmount(Math.abs(net), expenseCurrency(exp))} @@ -568,7 +574,7 @@ export function HistoryTab() { )} > {isOwed ? '+' : '-'} - {formatAmount(Math.abs(amount), currency)} + {formatAmount(Math.abs(amount), tripCurrency)} @@ -708,8 +714,14 @@ export function HistoryTab() {

- {formatAmount(exp.totalAmount, currency)} + {formatAmount(exp.totalAmount, expenseCurrency(exp))}

+ {expenseCurrency(exp) === 'KRW' && + formatKrwAsTwd(exp.totalAmount, exp.exchangeRateKrwPerTwd) && ( +

+ ≈ {formatKrwAsTwd(exp.totalAmount, exp.exchangeRateKrwPerTwd)} +

+ )}

{t('history.participants', { count: exp.participantIds.length })}

@@ -830,7 +842,7 @@ export function HistoryTab() { {m.name}
- {formatAmount(amt, currency)} + {formatAmount(amt, expenseCurrency(exp))}
); diff --git a/apps/split-meow/src/config/__tests__/currencies.test.ts b/apps/split-meow/src/config/__tests__/currencies.test.ts index 6602d79e1..50094fce3 100644 --- a/apps/split-meow/src/config/__tests__/currencies.test.ts +++ b/apps/split-meow/src/config/__tests__/currencies.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { formatAmount, detectCurrencyFromTimezone, getCurrencySymbol } from '../currencies'; +import { + formatAmount, + formatKrwAsTwd, + detectCurrencyFromTimezone, + getCurrencySymbol, +} from '../currencies'; describe('formatAmount', () => { it('TWD:以 NT$ 前綴格式化', () => { @@ -23,6 +28,25 @@ describe('formatAmount', () => { }); }); +describe('formatKrwAsTwd', () => { + it('依匯率將 KRW 換算為 TWD 顯示字串', () => { + expect(formatKrwAsTwd(30000, 43.5)).toBe('NT$ 690'); + }); + + it('匯率為 null 時回傳 null', () => { + expect(formatKrwAsTwd(30000, null)).toBeNull(); + }); + + it('匯率為 0 或負值時回傳 null', () => { + expect(formatKrwAsTwd(30000, 0)).toBeNull(); + expect(formatKrwAsTwd(30000, -1)).toBeNull(); + }); + + it('匯率為 undefined(舊資料)時回傳 null', () => { + expect(formatKrwAsTwd(30000, undefined)).toBeNull(); + }); +}); + describe('getCurrencySymbol', () => { it('TWD → NT$', () => { expect(getCurrencySymbol('TWD')).toBe('NT$'); diff --git a/apps/split-meow/src/config/currencies.ts b/apps/split-meow/src/config/currencies.ts index 6b969e3d1..f816459c1 100644 --- a/apps/split-meow/src/config/currencies.ts +++ b/apps/split-meow/src/config/currencies.ts @@ -36,3 +36,8 @@ export function formatAmount(amount: number, currency: CurrencyCode): string { export function getCurrencySymbol(currency: CurrencyCode): string { return CURRENCIES[currency].symbol; } + +export function formatKrwAsTwd(krwAmount: number, rate: number | null | undefined): string | null { + if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null; + return formatAmount(krwAmount / rate, 'TWD'); +} diff --git a/apps/split-meow/src/store/__tests__/useStore.test.ts b/apps/split-meow/src/store/__tests__/useStore.test.ts index c41ebe0dc..bf214907b 100644 --- a/apps/split-meow/src/store/__tests__/useStore.test.ts +++ b/apps/split-meow/src/store/__tests__/useStore.test.ts @@ -207,6 +207,22 @@ describe('useStore', () => { expect(useStore.getState().activeTab).toBe('history'); }); + it('保存記帳當下的幣別與匯率快照(KRW)', () => { + useStore.setState({ calculatorValue: '30000', currency: 'KRW', krwPerTwd: 43.5 }); + act(() => useStore.getState().saveExpense()); + const exp = useStore.getState().expenses[0]!; + expect(exp.currency).toBe('KRW'); + expect(exp.exchangeRateKrwPerTwd).toBe(43.5); + }); + + it('幣別快照不受日後切換幣別影響', () => { + useStore.setState({ calculatorValue: '1000', currency: 'TWD', krwPerTwd: null }); + act(() => useStore.getState().saveExpense()); + act(() => useStore.getState().setCurrency('KRW')); + const exp = useStore.getState().expenses[0]!; + expect(exp.currency).toBe('TWD'); + }); + it('儲存後清空計算機', () => { useStore.setState({ calculatorValue: '100', expenseNote: '午餐' }); act(() => useStore.getState().saveExpense()); diff --git a/apps/split-meow/src/store/useStore.ts b/apps/split-meow/src/store/useStore.ts index adc8b47c8..4bae74056 100644 --- a/apps/split-meow/src/store/useStore.ts +++ b/apps/split-meow/src/store/useStore.ts @@ -32,6 +32,8 @@ export interface ExpenseRecord { note: string; category?: ExpenseCategory; createdAt: number; + currency?: CurrencyCode; + exchangeRateKrwPerTwd?: number | null; } export interface Trip { @@ -266,6 +268,8 @@ export const useStore = create()( note: state.expenseNote.trim(), ...(state.expenseCategory ? { category: state.expenseCategory } : {}), createdAt: Date.now(), + currency: state.currency, + exchangeRateKrwPerTwd: state.krwPerTwd, }; return {