diff --git a/.changeset/split-meow-mixed-currency-guard.md b/.changeset/split-meow-mixed-currency-guard.md new file mode 100644 index 000000000..3d60df7b6 --- /dev/null +++ b/.changeset/split-meow-mixed-currency-guard.md @@ -0,0 +1,5 @@ +--- +'@app/split-meow': patch +--- + +修正混幣行程(同一行程記到多種幣別)會把不同幣別金額直接相加、顯示錯誤總額與結算的問題;偵測到多幣別時改顯示警告並隱藏無效的總額與結算(含分享文字),引導使用者改用單一幣別記帳。 diff --git a/apps/split-meow/src/components/HistoryTab.tsx b/apps/split-meow/src/components/HistoryTab.tsx index 438605538..426b2fac6 100644 --- a/apps/split-meow/src/components/HistoryTab.tsx +++ b/apps/split-meow/src/components/HistoryTab.tsx @@ -183,6 +183,8 @@ export function HistoryTab() { const tripCurrency: CurrencyCode = tripExpenses[tripExpenses.length - 1]?.currency ?? currency; // 取得單筆記錄的顯示幣別(優先使用記帳當下快照,舊資料 fallback 主導幣別)。 const expenseCurrency = (exp: ExpenseRecord): CurrencyCode => exp.currency ?? tripCurrency; + // 混幣行程:跨幣別直接相加的總額與結算為無效運算,改顯示警告而非誤導數字。 + const isMixedCurrency = new Set(tripExpenses.map((exp) => expenseCurrency(exp))).size > 1; // 計算各人餘額 const balances: Record = {}; @@ -222,7 +224,9 @@ export function HistoryTab() { const lines: string[] = [ `🐾 喵喵分帳 — ${tripName}`, `${'─'.repeat(24)}`, - `💰 總花費:${formatAmount(totalSpent, tripCurrency)}`, + isMixedCurrency + ? `⚠️ ${t('history.mixed_currency_warning')}` + : `💰 總花費:${formatAmount(totalSpent, tripCurrency)}`, '', ]; if (tripExpenses.length > 0) { @@ -239,7 +243,7 @@ export function HistoryTab() { }); lines.push(''); } - if (settlements.length > 0) { + if (settlements.length > 0 && !isMixedCurrency) { lines.push(`💸 結清方式`); settlements.forEach((s) => { const from = members.find((m) => m.id === s.from)?.name ?? s.from; @@ -281,9 +285,15 @@ export function HistoryTab() { {t('history.total_spent')} -

- {formatAmount(totalSpent, tripCurrency)} -

+ {isMixedCurrency ? ( +

+ {t('history.mixed_currency_warning')} +

+ ) : ( +

+ {formatAmount(totalSpent, tripCurrency)} +

+ )}
@@ -327,7 +337,7 @@ export function HistoryTab() { {/* 結清方式 */} - {settlements.length > 0 && ( + {settlements.length > 0 && !isMixedCurrency && (

{t('history.settlements')} diff --git a/apps/split-meow/src/components/__tests__/HomeAndHistorySmoke.test.tsx b/apps/split-meow/src/components/__tests__/HomeAndHistorySmoke.test.tsx index 647d6e714..7c5b47892 100644 --- a/apps/split-meow/src/components/__tests__/HomeAndHistorySmoke.test.tsx +++ b/apps/split-meow/src/components/__tests__/HomeAndHistorySmoke.test.tsx @@ -223,4 +223,46 @@ describe('HistoryTab', () => { // Should render settlement amounts somewhere expect(document.body).toBeTruthy(); }); + + it('混幣行程顯示警告並隱藏總額與結算(避免跨幣別錯誤加總)', () => { + useStore.setState({ + expenses: [ + { ...EXPENSE_1, id: 'mix-twd', currency: 'TWD', totalAmount: 300 }, + { + id: 'mix-krw', + tripId: 'trip-1', + type: 'split_evenly' as const, + participantIds: ['me', 'm1'], + paidBy: 'm1', + totalAmount: 9000, + perPersonAmounts: { me: 4500, m1: 4500 }, + note: '', + createdAt: Date.now() + 1, + currency: 'KRW', + }, + ], + }); + renderWith(); + expect(screen.getByText(i18n.t('history.mixed_currency_warning'))).toBeInTheDocument(); + // 結算區塊標題不應出現:跨幣別結算為無效運算,不得顯示誤導金額。 + expect(screen.queryByText(i18n.t('history.settlements'))).not.toBeInTheDocument(); + }); + + it('單一幣別行程不顯示混幣警告', () => { + useStore.setState({ + expenses: [ + { ...EXPENSE_1, id: 'same-1', currency: 'TWD' }, + { + ...EXPENSE_1, + id: 'same-2', + currency: 'TWD', + paidBy: 'm1', + totalAmount: 60, + perPersonAmounts: { me: 30, m1: 30 }, + }, + ], + }); + renderWith(); + expect(screen.queryByText(i18n.t('history.mixed_currency_warning'))).not.toBeInTheDocument(); + }); }); diff --git a/apps/split-meow/src/i18n.ts b/apps/split-meow/src/i18n.ts index 3fdaf5bf8..8ee02b4a0 100644 --- a/apps/split-meow/src/i18n.ts +++ b/apps/split-meow/src/i18n.ts @@ -33,6 +33,7 @@ const resources = { total_spent: '總花費', members_count: '由 {{count}} 位探險家分攤', settlements: '結清方式', + mixed_currency_warning: '此行程含多種幣別,無法合併總額與結算,請改用單一幣別記帳', pay_to: '{{from}} 付給 {{to}}', balances: '各人結算', owed: '應收', @@ -123,6 +124,8 @@ const resources = { total_spent: 'Total Spent', members_count: '{{count}} adventurers splitting', settlements: 'How to Settle', + mixed_currency_warning: + 'This trip mixes currencies; totals and settlement cannot be combined. Use a single currency.', pay_to: '{{from}} pays {{to}}', balances: 'Balances', owed: 'Receives', @@ -213,6 +216,8 @@ const resources = { total_spent: '총 지출', members_count: '{{count}}명이 분담', settlements: '정산 방법', + mixed_currency_warning: + '이 여행에는 여러 통화가 섞여 있어 합계와 정산을 합칠 수 없습니다. 단일 통화를 사용하세요.', pay_to: '{{from}} → {{to}}', balances: '개인 정산', owed: '받을 돈', @@ -303,6 +308,8 @@ const resources = { total_spent: '総支出', members_count: '{{count}}人で割り勘', settlements: '精算方法', + mixed_currency_warning: + 'この旅行は複数の通貨が混在しており、合計と精算を統合できません。単一通貨を使用してください。', pay_to: '{{from}} → {{to}}', balances: '各自の精算', owed: '受け取り',