From 47353cc4db67872c7bd5bb144052fb0e5e415949 Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 22:57:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(split-meow):=20=E6=B7=B7=E5=B9=A3=E8=A1=8C?= =?UTF-8?q?=E7=A8=8B=E5=81=9C=E6=AD=A2=E8=B7=A8=E5=B9=A3=E5=88=A5=E9=8C=AF?= =?UTF-8?q?=E8=AA=A4=E5=8A=A0=E7=B8=BD=E8=88=87=E7=B5=90=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 混幣行程的總額、各人餘額與結算改為短路不計算,避免裸加不同幣別 - 隱藏混幣情境的「各人結算」區塊,僅保留警告,杜絕誤導金額 - 抽出 resolveExpenseCurrency 等純函式至 currencies SSOT 並補單元測試 - 編輯既有費用改用該筆 currency 快照顯示符號,不再依賴全域幣別 測試:split-meow typecheck 通過、vitest 3 檔 71 測試全過 --- ...plit-meow-mixed-currency-balances-guard.md | 5 ++ apps/split-meow/src/components/HistoryTab.tsx | 72 +++++++++---------- .../__tests__/HomeAndHistorySmoke.test.tsx | 2 +- .../src/config/__tests__/currencies.test.ts | 32 +++++++++ apps/split-meow/src/config/currencies.ts | 44 ++++++++++++ apps/split-meow/src/i18n.ts | 3 +- apps/split-meow/src/store/useStore.ts | 2 - 7 files changed, 118 insertions(+), 42 deletions(-) create mode 100644 .changeset/split-meow-mixed-currency-balances-guard.md diff --git a/.changeset/split-meow-mixed-currency-balances-guard.md b/.changeset/split-meow-mixed-currency-balances-guard.md new file mode 100644 index 000000000..4115a622c --- /dev/null +++ b/.changeset/split-meow-mixed-currency-balances-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 426b2fac6..cb105d205 100644 --- a/apps/split-meow/src/components/HistoryTab.tsx +++ b/apps/split-meow/src/components/HistoryTab.tsx @@ -1,7 +1,15 @@ import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; import { useStore, type Member, type ExpenseRecord } from '../store/useStore'; -import { formatAmount, getCurrencySymbol, formatKrwAsTwd } from '../config/currencies'; -import type { CurrencyCode } from '../config/currencies'; +import { + formatAmount, + getCurrencySymbol, + formatKrwAsTwd, + resolveExpenseCurrency, + resolveTripCurrency, + isMixedCurrencyTrip, + computeMemberBalances, +} from '../config/currencies'; import { format } from 'date-fns'; import { useRef, useState } from 'react'; import { cn } from '../lib/utils'; @@ -113,18 +121,20 @@ function ParticipantAvatars({ export function HistoryTab() { const { t } = useTranslation(); - const { - expenses, - members, - trips, - currentTripId, - deleteExpense, - updateExpenseNote, - updateExpense, - settledPayments, - toggleSettlement, - currency, - } = useStore(); + const { expenses, members, trips, currentTripId, currency, settledPayments } = useStore( + useShallow((s) => ({ + expenses: s.expenses, + members: s.members, + trips: s.trips, + currentTripId: s.currentTripId, + currency: s.currency, + settledPayments: s.settledPayments, + })), + ); + const deleteExpense = useStore((s) => s.deleteExpense); + const updateExpenseNote = useStore((s) => s.updateExpenseNote); + const updateExpense = useStore((s) => s.updateExpense); + const toggleSettlement = useStore((s) => s.toggleSettlement); const [expandedId, setExpandedId] = useState(null); const [expandedMemberId, setExpandedMemberId] = useState(null); const [editingNoteId, setEditingNoteId] = useState(null); @@ -176,26 +186,13 @@ export function HistoryTab() { participantIds: e.participantIds.filter((id) => id in perPersonAmounts), }; }); - const totalSpent = tripExpenses.reduce((sum, e) => sum + e.totalAmount, 0); - - // trip 主導幣別:採用該行程最舊一筆記錄的幣別(trip 建立時的幣別), - // 舊資料無幣別時 fallback 至當前全域幣別。彙總與結算統一以此幣別顯示。 - 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 = {}; - tripExpenses.forEach((exp) => { - balances[exp.paidBy] = (balances[exp.paidBy] ?? 0) + exp.totalAmount; - Object.entries(exp.perPersonAmounts).forEach(([memberId, amount]) => { - balances[memberId] = (balances[memberId] ?? 0) - amount; - }); - }); - const settlements = calculateSettlements({ ...balances }); + const tripCurrency = resolveTripCurrency(tripExpenses, currency); + const expenseCurrency = (exp: ExpenseRecord) => resolveExpenseCurrency(exp, tripCurrency); + const isMixedCurrency = isMixedCurrencyTrip(tripExpenses, tripCurrency); + const totalSpent = isMixedCurrency ? 0 : tripExpenses.reduce((sum, e) => sum + e.totalAmount, 0); + const balances = isMixedCurrency ? {} : computeMemberBalances(tripExpenses); + const settlements = isMixedCurrency ? [] : calculateSettlements(balances); const startEditNote = (expId: string, currentNote: string, e: React.MouseEvent) => { e.stopPropagation(); @@ -407,7 +404,7 @@ export function HistoryTab() { )} {/* 各人結算 */} - {Object.keys(balances).length > 0 && ( + {!isMixedCurrency && Object.keys(balances).length > 0 && (

{t('history.balances')} @@ -926,7 +923,8 @@ interface EditExpenseSheetProps { function EditExpenseSheet({ expense, members, onSave, onClose }: EditExpenseSheetProps) { const { t } = useTranslation(); - const { currency } = useStore(); + const globalCurrency = useStore((s) => s.currency); + const expenseCurrency = expense.currency ?? globalCurrency; const isEvenly = expense.type === 'split_evenly'; const [totalInput, setTotalInput] = useState( @@ -992,7 +990,7 @@ function EditExpenseSheet({ expense, members, onSave, onClose }: EditExpenseShee {isEvenly ? (
- {getCurrencySymbol(currency)} + {getCurrencySymbol(expenseCurrency)} - {getCurrencySymbol(currency)} + {getCurrencySymbol(expenseCurrency)} { }); renderWith(); expect(screen.getByText(i18n.t('history.mixed_currency_warning'))).toBeInTheDocument(); - // 結算區塊標題不應出現:跨幣別結算為無效運算,不得顯示誤導金額。 expect(screen.queryByText(i18n.t('history.settlements'))).not.toBeInTheDocument(); + expect(screen.queryByText(i18n.t('history.balances'))).not.toBeInTheDocument(); }); it('單一幣別行程不顯示混幣警告', () => { diff --git a/apps/split-meow/src/config/__tests__/currencies.test.ts b/apps/split-meow/src/config/__tests__/currencies.test.ts index 4d32f2509..212e593dd 100644 --- a/apps/split-meow/src/config/__tests__/currencies.test.ts +++ b/apps/split-meow/src/config/__tests__/currencies.test.ts @@ -4,6 +4,10 @@ import { detectCurrencyFromTimezone, getCurrencySymbol, formatKrwAsTwd, + resolveExpenseCurrency, + resolveTripCurrency, + isMixedCurrencyTrip, + computeMemberBalances, } from '../currencies'; describe('formatAmount', () => { @@ -84,3 +88,31 @@ describe('formatKrwAsTwd', () => { expect(formatKrwAsTwd(30000, undefined)).toBeNull(); }); }); + +describe('trip currency helpers', () => { + it('resolveTripCurrency 取最舊一筆的幣別', () => { + expect(resolveTripCurrency([{ currency: 'KRW' }, { currency: 'TWD' }], 'TWD')).toBe('TWD'); + }); + + it('isMixedCurrencyTrip 偵測混幣行程', () => { + expect(isMixedCurrencyTrip([{ currency: 'TWD' }, { currency: 'KRW' }], 'TWD')).toBe(true); + expect(isMixedCurrencyTrip([{ currency: 'TWD' }, { currency: 'TWD' }], 'TWD')).toBe(false); + }); + + it('computeMemberBalances 僅加總同幣別 raw 金額', () => { + expect( + computeMemberBalances([ + { + paidBy: 'a', + totalAmount: 100, + perPersonAmounts: { a: 50, b: 50 }, + }, + ]), + ).toEqual({ a: 50, b: -50 }); + }); + + it('resolveExpenseCurrency 舊資料 fallback 行程幣別', () => { + expect(resolveExpenseCurrency({}, 'KRW')).toBe('KRW'); + expect(resolveExpenseCurrency({ currency: 'TWD' }, 'KRW')).toBe('TWD'); + }); +}); diff --git a/apps/split-meow/src/config/currencies.ts b/apps/split-meow/src/config/currencies.ts index da2fc146e..ffc271bca 100644 --- a/apps/split-meow/src/config/currencies.ts +++ b/apps/split-meow/src/config/currencies.ts @@ -37,6 +37,50 @@ export function getCurrencySymbol(currency: CurrencyCode): string { return CURRENCIES[currency].symbol; } +/** 單筆費用的顯示幣別;舊資料缺欄位時 fallback 至行程主導幣別。 */ +export function resolveExpenseCurrency( + expense: { currency?: CurrencyCode }, + tripCurrency: CurrencyCode, +): CurrencyCode { + return expense.currency ?? tripCurrency; +} + +/** 行程主導幣別:最舊一筆記帳的幣別快照,舊資料 fallback 全域幣別。 */ +export function resolveTripCurrency( + expenses: { currency?: CurrencyCode }[], + globalCurrency: CurrencyCode, +): CurrencyCode { + return expenses[expenses.length - 1]?.currency ?? globalCurrency; +} + +/** 行程是否混用多種幣別;混幣時不可加總或結算。 */ +export function isMixedCurrencyTrip( + expenses: { currency?: CurrencyCode }[], + tripCurrency: CurrencyCode, +): boolean { + if (expenses.length === 0) return false; + const codes = new Set(expenses.map((exp) => resolveExpenseCurrency(exp, tripCurrency))); + return codes.size > 1; +} + +/** 同幣別行程的成員餘額;僅在 `isMixedCurrencyTrip` 為 false 時有意義。 */ +export function computeMemberBalances( + expenses: { + paidBy: string; + totalAmount: number; + perPersonAmounts: Record; + }[], +): Record { + const balances: Record = {}; + for (const exp of expenses) { + balances[exp.paidBy] = (balances[exp.paidBy] ?? 0) + exp.totalAmount; + for (const [memberId, amount] of Object.entries(exp.perPersonAmounts)) { + balances[memberId] = (balances[memberId] ?? 0) - amount; + } + } + return balances; +} + /** * 將 KRW 金額依匯率快照換算為 TWD 顯示字串(用於 KRW 記帳時的副標)。 * rate 為 1 TWD = rate KRW(賣出價);rate 無效時回傳 null 表示無法換算。 diff --git a/apps/split-meow/src/i18n.ts b/apps/split-meow/src/i18n.ts index 8ee02b4a0..2b7110a78 100644 --- a/apps/split-meow/src/i18n.ts +++ b/apps/split-meow/src/i18n.ts @@ -1,6 +1,5 @@ /** - * i18n 初始化設定 - * 支援語言:繁體中文(預設)、英文、韓文、日文 + * i18n 初始化:繁中(預設)、英文、韓文、日文。 */ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; diff --git a/apps/split-meow/src/store/useStore.ts b/apps/split-meow/src/store/useStore.ts index 547dd0204..79b891073 100644 --- a/apps/split-meow/src/store/useStore.ts +++ b/apps/split-meow/src/store/useStore.ts @@ -105,7 +105,6 @@ const INITIAL_SEEDS = { m2: 'split-meow-luna', }; -/** ロケール別のランダム名生成素材 */ const NAME_PARTS: Record = { 'zh-TW': { prefixes: ['奶油', '布丁', '麻糬', '棉花', '糰子', '可可', '芝麻', '蜜桃', '焦糖', '雲朵'], @@ -270,7 +269,6 @@ export const useStore = create()( note: state.expenseNote.trim(), ...(state.expenseCategory ? { category: state.expenseCategory } : {}), createdAt: Date.now(), - // 記帳當下的幣別與匯率快照,確保歷史金額不受日後切換幣別影響並可回溯換算 currency: state.currency, exchangeRateKrwPerTwd: state.krwPerTwd, };