Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/split-meow-mixed-currency-balances-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/split-meow': patch
---

修正混幣行程的結算金額:當同一行程混用多種幣別時,總額、各人餘額與結算不再把不同幣別的數字直接相加,改為僅顯示混幣警告並隱藏「各人結算」區塊,避免誤導金額。編輯既有費用時改用該筆記帳當下的幣別符號。
72 changes: 35 additions & 37 deletions apps/split-meow/src/components/HistoryTab.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string | null>(null);
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null);
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
Expand Down Expand Up @@ -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<string, number> = {};
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();
Expand Down Expand Up @@ -407,7 +404,7 @@ export function HistoryTab() {
)}

{/* 各人結算 */}
{Object.keys(balances).length > 0 && (
{!isMixedCurrency && Object.keys(balances).length > 0 && (
<section className="mb-10">
<h3 className="text-xs font-medium uppercase tracking-widest text-outline px-2 mb-4">
{t('history.balances')}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -992,7 +990,7 @@ function EditExpenseSheet({ expense, members, onSave, onClose }: EditExpenseShee
{isEvenly ? (
<div className="flex items-center gap-3 bg-surface-container rounded-2xl px-4 py-3">
<span className="text-on-surface-variant text-sm font-medium shrink-0">
{getCurrencySymbol(currency)}
{getCurrencySymbol(expenseCurrency)}
</span>
<input
type="number"
Expand All @@ -1018,7 +1016,7 @@ function EditExpenseSheet({ expense, members, onSave, onClose }: EditExpenseShee
{m.name}
</span>
<span className="text-on-surface-variant text-sm shrink-0">
{getCurrencySymbol(currency)}
{getCurrencySymbol(expenseCurrency)}
</span>
<input
type="number"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ describe('HistoryTab', () => {
});
renderWith(<HistoryTab />);
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('單一幣別行程不顯示混幣警告', () => {
Expand Down
32 changes: 32 additions & 0 deletions apps/split-meow/src/config/__tests__/currencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
detectCurrencyFromTimezone,
getCurrencySymbol,
formatKrwAsTwd,
resolveExpenseCurrency,
resolveTripCurrency,
isMixedCurrencyTrip,
computeMemberBalances,
} from '../currencies';

describe('formatAmount', () => {
Expand Down Expand Up @@ -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');
});
});
44 changes: 44 additions & 0 deletions apps/split-meow/src/config/currencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
s123104 marked this conversation as resolved.
}

/** 行程是否混用多種幣別;混幣時不可加總或結算。 */
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<string, number>;
}[],
): Record<string, number> {
const balances: Record<string, number> = {};
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 表示無法換算。
Expand Down
3 changes: 1 addition & 2 deletions apps/split-meow/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/**
* i18n 初始化設定
* 支援語言:繁體中文(預設)、英文、韓文、日文
* i18n 初始化:繁中(預設)、英文、韓文、日文。
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
Expand Down
2 changes: 0 additions & 2 deletions apps/split-meow/src/store/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ const INITIAL_SEEDS = {
m2: 'split-meow-luna',
};

/** ロケール別のランダム名生成素材 */
const NAME_PARTS: Record<string, { prefixes: string[]; suffixes: string[] }> = {
'zh-TW': {
prefixes: ['奶油', '布丁', '麻糬', '棉花', '糰子', '可可', '芝麻', '蜜桃', '焦糖', '雲朵'],
Expand Down Expand Up @@ -270,7 +269,6 @@ export const useStore = create<AppState>()(
note: state.expenseNote.trim(),
...(state.expenseCategory ? { category: state.expenseCategory } : {}),
createdAt: Date.now(),
// 記帳當下的幣別與匯率快照,確保歷史金額不受日後切換幣別影響並可回溯換算
currency: state.currency,
exchangeRateKrwPerTwd: state.krwPerTwd,
};
Expand Down
Loading