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-proactive-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/split-meow': patch
---

切換幣別或記帳若會使同一行程混用多種幣別,改在當下以確認對話框提示;編輯舊費用時改以行程主導幣別解析符號,避免顯示錯誤幣別。時區自動偵測幣別若會造成混幣則靜默跳過,不再覆寫使用者幣別。
21 changes: 20 additions & 1 deletion apps/split-meow/src/components/Calculator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useStore } from '../store/useStore';
import { cn } from '../lib/utils';
import { evaluateExpression } from '../lib/evaluateExpression';
import { confirmMixedCurrencyIfNeeded } from '../config/currencies';

interface CalculatorProps {
onPawParticle?: (x: number, y: number) => void;
Expand All @@ -17,6 +18,9 @@ export function Calculator({ onPawParticle }: CalculatorProps = {}) {
setCalculatorValue,
setItemizedValue,
saveExpense,
expenses,
currentTripId,
currency,
} = useStore();

const currentValue =
Expand All @@ -31,6 +35,21 @@ export function Calculator({ onPawParticle }: CalculatorProps = {}) {
? evaluateExpression(currentValue) > 0
: Object.values(itemizedValues).some((v) => evaluateExpression(v) > 0);

const handleSave = () => {
const tripExpenses = expenses.filter((e) => e.tripId === currentTripId);
if (
!confirmMixedCurrencyIfNeeded(
tripExpenses,
currency,
currency,
t('history.mixed_currency_confirm'),
)
) {
return;
}
saveExpense();
};

const handlePress = (key: string) => {
if (splitMode === 'itemized' && !focusedMemberId) return;
if ('vibrate' in navigator) navigator.vibrate(8);
Expand Down Expand Up @@ -191,7 +210,7 @@ export function Calculator({ onPawParticle }: CalculatorProps = {}) {
</button>
))}
<button
onClick={canSave ? saveExpense : undefined}
onClick={canSave ? handleSave : undefined}
disabled={!canSave}
className={cn(
'relative overflow-hidden rounded-full transition-all flex items-center justify-center gap-2 shadow-ambient text-lg sm:text-xl font-medium',
Expand Down
4 changes: 3 additions & 1 deletion apps/split-meow/src/components/HistoryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,9 @@ interface EditExpenseSheetProps {
function EditExpenseSheet({ expense, members, onSave, onClose }: EditExpenseSheetProps) {
const { t } = useTranslation();
const globalCurrency = useStore((s) => s.currency);
const expenseCurrency = expense.currency ?? globalCurrency;
const tripExpenses = useStore((s) => s.expenses.filter((e) => e.tripId === s.currentTripId));
const tripCurrency = resolveTripCurrency(tripExpenses, globalCurrency);
const expenseCurrency = resolveExpenseCurrency(expense, tripCurrency);
const isEvenly = expense.type === 'split_evenly';

const [totalInput, setTotalInput] = useState(
Expand Down
22 changes: 20 additions & 2 deletions apps/split-meow/src/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { MemberAvatar } from './MemberAvatar';
import i18n, { type SupportedLanguage } from '../i18n';
import { getDisplayVersion } from '../config/version';
import { cn } from '../lib/utils';
import { CURRENCIES, type CurrencyCode } from '../config/currencies';
import { CURRENCIES, type CurrencyCode, confirmMixedCurrencyIfNeeded } from '../config/currencies';

const LANGUAGES: { id: SupportedLanguage; flag: string; name: string }[] = [
{ id: 'zh-TW', flag: '🇹🇼', name: '繁中' },
Expand All @@ -28,6 +28,8 @@ export function SettingsTab() {
currencyManuallySet,
setCurrency,
rateUpdatedAt,
expenses,
currentTripId,
} = useStore();
const me = members.find((m) => m.id === 'me') ?? members[0] ?? null;
const [isEditing, setIsEditing] = useState(false);
Expand All @@ -49,6 +51,22 @@ export function SettingsTab() {
void i18n.changeLanguage(lang);
};

const handleCurrencyChange = (code: CurrencyCode) => {
if (code === currency) return;
const tripExpenses = expenses.filter((e) => e.tripId === currentTripId);
if (
!confirmMixedCurrencyIfNeeded(
tripExpenses,
currency,
code,
t('history.mixed_currency_confirm'),
)
) {
return;
}
setCurrency(code, true);
};

return (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500 space-y-8 pb-28">
<section className="flex flex-col items-center text-center space-y-6">
Expand Down Expand Up @@ -215,7 +233,7 @@ export function SettingsTab() {
return (
<button
key={code}
onClick={() => setCurrency(code, true)}
onClick={() => handleCurrencyChange(code)}
className={cn(
'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium transition-all cursor-pointer',
isActive
Expand Down
33 changes: 32 additions & 1 deletion apps/split-meow/src/components/__tests__/Calculator.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
import { Calculator } from '../Calculator';
Expand Down Expand Up @@ -102,6 +102,37 @@ describe('Calculator', () => {
expect(useStore.getState().calculatorValue).toBe('');
});

it('混幣記帳前需確認,取消則不儲存', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
useStore.setState({
calculatorValue: '100',
currency: 'KRW',
currentTripId: 'default-trip',
expenses: [
{
id: 'exp-1',
tripId: 'default-trip',
type: 'split_evenly',
participantIds: ['me'],
paidBy: 'me',
totalAmount: 100,
perPersonAmounts: { me: 100 },
note: '',
createdAt: 1,
currency: 'TWD',
},
],
});
renderCalc();
const allBtns = document.querySelectorAll('button');
const saveBtn = allBtns[allBtns.length - 1]!;
fireEvent.click(saveBtn);
expect(confirmSpy).toHaveBeenCalledWith(i18n.t('history.mixed_currency_confirm'));
expect(useStore.getState().expenses).toHaveLength(1);
expect(useStore.getState().calculatorValue).toBe('100');
confirmSpy.mockRestore();
});

it('itemized 模式下無 focusedMemberId 時按鍵不更新值', () => {
useStore.setState({
splitMode: 'itemized',
Expand Down
56 changes: 55 additions & 1 deletion apps/split-meow/src/components/__tests__/SettingsTab.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
import { SettingsTab } from '../SettingsTab';
Expand Down Expand Up @@ -85,4 +85,58 @@ describe('SettingsTab', () => {
expect(useStore.getState().members).toHaveLength(3);
}
});

it('切換幣別若會混幣需確認,取消則不變更', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
useStore.setState({
currency: 'TWD',
currentTripId: 'default-trip',
expenses: [
{
id: 'exp-1',
tripId: 'default-trip',
type: 'split_evenly',
participantIds: ['me'],
paidBy: 'me',
totalAmount: 100,
perPersonAmounts: { me: 100 },
note: '',
createdAt: 1,
currency: 'TWD',
},
],
});
renderSettings();
fireEvent.click(screen.getByText('₩'));
expect(confirmSpy).toHaveBeenCalledWith(i18n.t('history.mixed_currency_confirm'));
expect(useStore.getState().currency).toBe('TWD');
confirmSpy.mockRestore();
});

it('切換幣別若會混幣且確認則變更', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
useStore.setState({
currency: 'TWD',
currentTripId: 'default-trip',
expenses: [
{
id: 'exp-1',
tripId: 'default-trip',
type: 'split_evenly',
participantIds: ['me'],
paidBy: 'me',
totalAmount: 100,
perPersonAmounts: { me: 100 },
note: '',
createdAt: 1,
currency: 'TWD',
},
],
});
renderSettings();
fireEvent.click(screen.getByText('₩'));
expect(confirmSpy).toHaveBeenCalledWith(i18n.t('history.mixed_currency_confirm'));
expect(useStore.getState().currency).toBe('KRW');
confirmSpy.mockRestore();
});
});
15 changes: 15 additions & 0 deletions apps/split-meow/src/config/__tests__/currencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolveExpenseCurrency,
resolveTripCurrency,
isMixedCurrencyTrip,
wouldCreateMixedCurrencyTrip,
computeMemberBalances,
} from '../currencies';

Expand Down Expand Up @@ -115,4 +116,18 @@ describe('trip currency helpers', () => {
expect(resolveExpenseCurrency({}, 'KRW')).toBe('KRW');
expect(resolveExpenseCurrency({ currency: 'TWD' }, 'KRW')).toBe('TWD');
});

it('wouldCreateMixedCurrencyTrip 僅在單幣行程將混幣時為 true', () => {
expect(wouldCreateMixedCurrencyTrip([], 'TWD', 'KRW')).toBe(false);
expect(wouldCreateMixedCurrencyTrip([{ currency: 'TWD' }], 'TWD', 'TWD')).toBe(false);
expect(wouldCreateMixedCurrencyTrip([{ currency: 'TWD' }], 'TWD', 'KRW')).toBe(true);
expect(
wouldCreateMixedCurrencyTrip([{ currency: 'TWD' }, { currency: 'KRW' }], 'TWD', 'TWD'),
).toBe(false);
});

it('wouldCreateMixedCurrencyTrip 舊資料缺 currency 欄位時仍偵測混幣風險', () => {
expect(wouldCreateMixedCurrencyTrip([{}], 'TWD', 'KRW')).toBe(true);
expect(wouldCreateMixedCurrencyTrip([{ currency: undefined }], 'TWD', 'KRW')).toBe(true);
});
});
28 changes: 28 additions & 0 deletions apps/split-meow/src/config/currencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,34 @@ export function isMixedCurrencyTrip(
return codes.size > 1;
}

/** 新增一筆 `incomingCurrency` 是否會使原本單幣行程變成混幣。 */
export function wouldCreateMixedCurrencyTrip(
tripExpenses: { currency?: CurrencyCode }[],
globalCurrency: CurrencyCode,
incomingCurrency: CurrencyCode,
): boolean {
if (tripExpenses.length === 0) return false;
const tripCurrency = resolveTripCurrency(tripExpenses, globalCurrency);
Comment thread
s123104 marked this conversation as resolved.
if (isMixedCurrencyTrip(tripExpenses, tripCurrency)) return false;
const lastExpense = tripExpenses[tripExpenses.length - 1];
if (lastExpense === undefined) return false;
const establishedCurrency = resolveExpenseCurrency(lastExpense, tripCurrency);
return incomingCurrency !== establishedCurrency;
}

/** 混幣風險時以 confirm 詢問;無風險或使用者確認則回傳 true。 */
export function confirmMixedCurrencyIfNeeded(
tripExpenses: { currency?: CurrencyCode }[],
globalCurrency: CurrencyCode,
incomingCurrency: CurrencyCode,
confirmMessage: string,
): boolean {
if (!wouldCreateMixedCurrencyTrip(tripExpenses, globalCurrency, incomingCurrency)) {
return true;
}
return window.confirm(confirmMessage);
}

/** 同幣別行程的成員餘額;僅在 `isMixedCurrencyTrip` 為 false 時有意義。 */
export function computeMemberBalances(
expenses: {
Expand Down
68 changes: 68 additions & 0 deletions apps/split-meow/src/hooks/__tests__/useCurrencyAutoDetect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type * as CurrenciesModule from '../../config/currencies';
import { useStore } from '../../store/useStore';

vi.mock('../../lib/exchangeRate', () => ({
fetchMoneyboxRate: vi.fn().mockResolvedValue({ krwPerTwd: 43.5, updatedAt: '2026-01-01' }),
}));

vi.mock('../../config/currencies', async (importOriginal) => {
const actual = await importOriginal<typeof CurrenciesModule>();
return {
...actual,
detectCurrencyFromTimezone: vi.fn(() => 'KRW' as const),
};
});

import { useCurrencyAutoDetect } from '../useCurrencyAutoDetect';
import { detectCurrencyFromTimezone } from '../../config/currencies';

describe('useCurrencyAutoDetect', () => {
beforeEach(() => {
vi.mocked(detectCurrencyFromTimezone).mockReturnValue('KRW');
useStore.setState({
currency: 'TWD',
currencyManuallySet: false,
currentTripId: 'default-trip',
expenses: [],
});
});

afterEach(() => {
vi.clearAllMocks();
});

it('混幣風險時跳過自動切換幣別', async () => {
useStore.setState({
expenses: [
{
id: 'exp-1',
tripId: 'default-trip',
type: 'split_evenly',
participantIds: ['me'],
paidBy: 'me',
totalAmount: 100,
perPersonAmounts: { me: 100 },
note: '',
createdAt: 1,
currency: 'TWD',
},
],
});

renderHook(() => useCurrencyAutoDetect());

await waitFor(() => {
expect(useStore.getState().currency).toBe('TWD');
});
});

it('無混幣風險時依時區自動切換', async () => {
renderHook(() => useCurrencyAutoDetect());

await waitFor(() => {
expect(useStore.getState().currency).toBe('KRW');
});
});
});
12 changes: 9 additions & 3 deletions apps/split-meow/src/hooks/useCurrencyAutoDetect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useStore } from '../store/useStore';
import { detectCurrencyFromTimezone } from '../config/currencies';
import { detectCurrencyFromTimezone, wouldCreateMixedCurrencyTrip } from '../config/currencies';
import { fetchMoneyboxRate } from '../lib/exchangeRate';

/**
Expand All @@ -13,7 +13,8 @@ import { fetchMoneyboxRate } from '../lib/exchangeRate';
*/
export function useCurrencyAutoDetect() {
useEffect(() => {
const { currencyManuallySet, setCurrency, setExchangeRate } = useStore.getState();
const { currencyManuallySet, setCurrency, setExchangeRate, currency, expenses, currentTripId } =
useStore.getState();

// 無論如何都更新匯率(供換算提示使用)
fetchMoneyboxRate()
Expand All @@ -28,6 +29,11 @@ export function useCurrencyAutoDetect() {
if (currencyManuallySet) return;

const detected = detectCurrencyFromTimezone();
if (detected) setCurrency(detected, false);
if (!detected) return;

const tripExpenses = expenses.filter((e) => e.tripId === currentTripId);
if (wouldCreateMixedCurrencyTrip(tripExpenses, currency, detected)) return;

setCurrency(detected, false);
}, []); // 僅 mount 時執行一次
}
Loading
Loading