mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(costs): amounts follow their currency's decimals and format, and payments count on the local day
Several small things in the costs views, on desktop and phone alike: - The outstanding card no longer says "You're all settled up" while a payment is still open in Settle up. - The custom split shows its suggested shares as a faint placeholder in the currency's own form (30000 for won, not 30000.00), so it no longer reads like an amount already typed. - Amount fields take only the decimals the currency has: won and yen take none, KWD and the other three-decimal currencies three. A switch of currency cuts what was already typed. 1000.555 won was saved as typed, listed as 1,001 and reopened as 1001. - The expense sheet on the phone prints shares and sums with the same formatter as the list, with grouping and the currency's decimals. - A payment recorded with Settle or Settle all is dated on the viewer's own day, and one without a date of its own is grouped by its UTC record time read on the local calendar. In Seoul before 09:00 it landed on the day before. - "1 expenses need a payer" now uses the plural forms of each language. Refs #2604
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateTicketShares, expenseEditorFor, newExpenseSeed, receiptToPrefill, finalBudgetFor, finalBudgetSources, formerParticipantRows, hasTicketSplit, knownPersonNames, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { amountInCurrency, amountPattern, calculateTicketShares, clampAmountDecimals, expenseEditorFor, newExpenseSeed, receiptToPrefill, finalBudgetFor, finalBudgetSources, formerParticipantRows, hasTicketSplit, knownPersonNames, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
@@ -199,6 +199,43 @@ describe('settlementDate', () => {
|
||||
it('is empty when neither is set', () => {
|
||||
expect(settlementDate({})).toBe('')
|
||||
})
|
||||
|
||||
it("reads the recorded UTC time on the viewer's own calendar day (#2604)", () => {
|
||||
const runnerTimeZone = process.env.TZ
|
||||
process.env.TZ = 'Asia/Seoul'
|
||||
try {
|
||||
// 23:30 UTC is 08:30 the next morning in Seoul.
|
||||
expect(settlementDate({ created_at: '2026-10-11 23:30:00' })).toBe('2026-10-12')
|
||||
expect(settlementDate({ created_at: '2026-10-11T23:30:00Z' })).toBe('2026-10-12')
|
||||
// A day the user picked is a calendar day already and stays as it is.
|
||||
expect(settlementDate({ settled_at: '2026-10-11', created_at: '2026-10-11 23:30:00' })).toBe('2026-10-11')
|
||||
} finally {
|
||||
if (runnerTimeZone === undefined) delete process.env.TZ
|
||||
else process.env.TZ = runnerTimeZone
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('amount decimals per currency (#2604)', () => {
|
||||
it('cuts a typed amount to the decimals the currency has', () => {
|
||||
expect(clampAmountDecimals('1000.555', 'KRW')).toBe('1000.')
|
||||
expect(clampAmountDecimals('1000.', 'JPY')).toBe('1000.')
|
||||
expect(amountInCurrency('12.34', 'KRW')).toBe('12')
|
||||
expect(amountInCurrency('12.34', 'EUR')).toBe('12.34')
|
||||
expect(clampAmountDecimals('10.555', 'EUR')).toBe('10.55')
|
||||
expect(clampAmountDecimals('1.5555', 'KWD')).toBe('1.555')
|
||||
expect(clampAmountDecimals('-12.5', 'EUR')).toBe('-12.5')
|
||||
expect(clampAmountDecimals('', 'KRW')).toBe('')
|
||||
})
|
||||
|
||||
it('takes no decimals for a currency without minor units, three for a three-decimal one', () => {
|
||||
expect(amountPattern('KRW', false).test('1000')).toBe(true)
|
||||
expect(amountPattern('KRW', false).test('1000.')).toBe(true)
|
||||
expect(amountPattern('KRW', false).test('1000.5')).toBe(false)
|
||||
expect(amountPattern('EUR', true).test('-10.55')).toBe(true)
|
||||
expect(amountPattern('EUR', false).test('10.555')).toBe(false)
|
||||
expect(amountPattern('KWD', false).test('1.555')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
|
||||
|
||||
@@ -14,16 +14,36 @@
|
||||
import { splitEqualShares as splitEqualMinorShares, toMinor } from '@trek/shared'
|
||||
import type { BudgetParticipantFinal, ReceiptRead } from '@trek/shared'
|
||||
import { amountToInputString, currencyDecimals } from '../../utils/formatters'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import type { ExpensePrefill } from './CostsPanel'
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
// The split and receipt fields guard their own precision on every keystroke, so the
|
||||
// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three
|
||||
// places, and the old fixed two rejected every keystroke after that, leaving the field
|
||||
// unusable until a digit was deleted (#2175). Never stricter than two places, so no
|
||||
// value a field accepts today can become uneditable.
|
||||
// unusable until a digit was deleted (#2175). A currency without minor units (KRW,
|
||||
// JPY) takes no decimals at all, or a won amount could carry cents the list then
|
||||
// rounds away (#2604). The point itself may stand, so a digit typed after it is
|
||||
// dropped instead of joining the whole won. Callers cut the input with
|
||||
// clampAmountDecimals first, so a longer value a field was seeded with stays editable.
|
||||
export const amountPattern = (currency: string, signed: boolean) =>
|
||||
new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`)
|
||||
new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${currencyDecimals(currency)}}$`)
|
||||
|
||||
/**
|
||||
* A dot-normalized amount as typed, cut to the decimals the currency has (#2604):
|
||||
* "1000.555" in won is "1000.", in euros "1000.55". Cut, not rounded, so the digits
|
||||
* left are the ones the user typed, and a won amount keeps its point so a digit
|
||||
* typed after it is dropped rather than read as one more whole won.
|
||||
*/
|
||||
export function clampAmountDecimals(value: string, currency: string): string {
|
||||
const dot = value.indexOf('.')
|
||||
return dot < 0 ? value : value.slice(0, dot + 1 + currencyDecimals(currency))
|
||||
}
|
||||
|
||||
/** An amount already typed, in the currency it was switched to: cut, and without a point left bare. */
|
||||
export function amountInCurrency(value: string, currency: string): string {
|
||||
return clampAmountDecimals(value, currency).replace(/\.$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Spread `amount` across `n` payers in whole cents so the parts sum back exactly.
|
||||
@@ -41,12 +61,17 @@ export function splitCents(amount: number, n: number): number[] {
|
||||
/**
|
||||
* The calendar day a settle-up payment counts on for grouping/filtering: its own
|
||||
* `settled_at` if the user set one, else the day it was recorded (`created_at`).
|
||||
* Mirrors `expense_date` falling back nowhere on a budget item — a settlement's
|
||||
* `created_at` doubled as its date before `settled_at` existed, so this keeps
|
||||
* every pre-existing row grouped exactly where it already was.
|
||||
* A settlement's `created_at` doubled as its date before `settled_at` existed.
|
||||
* It is SQLite's UTC clock ("2026-10-11 23:30:00"), so it is read as UTC and
|
||||
* placed on the viewer's own calendar: in Seoul that payment was made on the
|
||||
* 12th, not the 11th (#2604).
|
||||
*/
|
||||
export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string {
|
||||
return (s.settled_at || s.created_at || '').slice(0, 10)
|
||||
if (s.settled_at) return s.settled_at.slice(0, 10)
|
||||
if (!s.created_at) return ''
|
||||
const iso = s.created_at.replace(' ', 'T')
|
||||
const at = new Date(/(Z|[+-]\d{2}:?\d{2})$/i.test(iso) ? iso : `${iso}Z`)
|
||||
return Number.isNaN(at.getTime()) ? s.created_at.slice(0, 10) : localIsoDate(at)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// FE-COSTS-2604-001 to -004: the summary footers and won amounts in the Costs panel (#2604)
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import CostsPanel, { ExpenseModal } from './CostsPanel';
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
|
||||
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number };
|
||||
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number };
|
||||
const expense = (over: Partial<Omit<BudgetItem, 'members'>> & { members?: MemberFixture[] }): BudgetItem =>
|
||||
({ ...buildBudgetItem({ trip_id: 1 }), payers: [], members: [], ...over }) as unknown as BudgetItem;
|
||||
|
||||
function mount(items: BudgetItem[], settlement: { flows?: Flow[] } = {}) {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({ balances: [], flows: [], settlements: [], ...settlement })
|
||||
)
|
||||
);
|
||||
return render(<CostsPanel tripId={1} tripMembers={tripMembers} />);
|
||||
}
|
||||
|
||||
// Alice is the signed-in user; the euro display currency keeps FX an identity.
|
||||
function seedAlice() {
|
||||
resetAllStores();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } });
|
||||
}
|
||||
|
||||
describe('CostsPanel: summary footers and won amounts (#2604)', () => {
|
||||
beforeEach(seedAlice);
|
||||
|
||||
const owedByBob = () =>
|
||||
expense({
|
||||
id: 601,
|
||||
name: 'Museum',
|
||||
category: 'activities',
|
||||
total_price: 40,
|
||||
expense_date: '2025-06-15',
|
||||
payers: [{ user_id: 1, amount: 40 }],
|
||||
members: [
|
||||
{ user_id: 1, username: 'alice' },
|
||||
{ user_id: 2, username: 'bob' },
|
||||
],
|
||||
});
|
||||
|
||||
it('FE-COSTS-2604-001: the outstanding card says settled up only once no payment is left to make', async () => {
|
||||
mount([owedByBob()], {
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
|
||||
});
|
||||
await screen.findByText('Museum');
|
||||
expect(screen.getByRole('region', { name: 'Outstanding amount' })).not.toHaveTextContent("You're all settled up");
|
||||
});
|
||||
|
||||
it('FE-COSTS-2604-002: with nothing left to settle the outstanding card still says so', async () => {
|
||||
mount([owedByBob()]);
|
||||
await screen.findByText('Museum');
|
||||
expect(screen.getByRole('region', { name: 'Outstanding amount' })).toHaveTextContent("You're all settled up");
|
||||
});
|
||||
|
||||
it('FE-COSTS-2604-003: a single expense without a payer reads in the singular', async () => {
|
||||
mount([
|
||||
expense({
|
||||
id: 602,
|
||||
name: 'Taxi',
|
||||
category: 'transport',
|
||||
total_price: 30,
|
||||
members: [{ user_id: 1, username: 'alice' }],
|
||||
}),
|
||||
]);
|
||||
await screen.findByText('Taxi');
|
||||
expect(screen.getByRole('region', { name: 'Outstanding amount' })).toHaveTextContent('1 expense needs a payer');
|
||||
});
|
||||
|
||||
it('FE-COSTS-2604-004: a won expense shows whole won, and the custom split suggests its shares as a placeholder', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ExpenseModal
|
||||
tripId={1}
|
||||
base="KRW"
|
||||
people={tripMembers}
|
||||
me={1}
|
||||
onClose={() => {}}
|
||||
onSaved={vi.fn()}
|
||||
editing={expense({
|
||||
id: 603,
|
||||
name: 'BBQ',
|
||||
category: 'food',
|
||||
currency: 'KRW',
|
||||
total_price: 60000,
|
||||
payers: [{ user_id: 1, amount: 60000 }],
|
||||
members: [
|
||||
{ user_id: 1, username: 'alice' },
|
||||
{ user_id: 2, username: 'bob' },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue('60000')).toBeInTheDocument();
|
||||
expect(screen.getByText('₩30,000 per person')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Custom' }));
|
||||
const shares = screen.getAllByPlaceholderText('30000') as HTMLInputElement[];
|
||||
expect(shares).toHaveLength(2);
|
||||
// A suggestion, not a value: empty, and drawn in the faint placeholder style.
|
||||
expect(shares[0].value).toBe('');
|
||||
expect(shares[0].className).toContain('placeholder:text-content-faint');
|
||||
|
||||
// A digit after the point is dropped, not read as one more whole won.
|
||||
await user.type(shares[0], '1000.5');
|
||||
expect(shares[0].value).toBe('1000.');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { splitShareLabel } from './expenseFx'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
|
||||
import { formatMoney, amountToInputString, currencyDecimals, currencyLocale, localizeAmountInput, zeroAmountInput } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -283,8 +283,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<SummaryCard label={t('costs.outstanding')} sub={t('costs.outstandingSub')} amount={totals.outstanding} currency={base} locale={locale}
|
||||
icon={<AlertCircle size={18} />} tone="unfinished"
|
||||
foot={totals.outstandingCount > 0
|
||||
? <span><b>{totals.outstandingCount}</b> {t('costs.outstandingItems')}</span>
|
||||
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
||||
? <span><b>{totals.outstandingCount}</b> {t('costs.outstandingItems', { count: totals.outstandingCount })}</span>
|
||||
: !settlement?.flows?.length && <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
||||
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
|
||||
icon={<BarChart3 size={18} />} tone="total"
|
||||
foot={<>
|
||||
@@ -1134,7 +1134,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency,
|
||||
<div className="flex items-center rounded-[10px] border border-edge bg-surface-input px-3" style={{ height: FIELD_H }}>
|
||||
<span className="text-content-faint" style={fs(14, 'body')}>{SYMBOLS[cur] || (cur + ' ')}</span>
|
||||
{/* Typed and shown the way the expense total is, in the reader's own decimal mark. */}
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', cur)} value={localizeAmountInput(amount, cur)}
|
||||
<NumericInput mode="decimal" placeholder={zeroAmountInput(cur)} value={localizeAmountInput(amount, cur)}
|
||||
onValueChange={v => setAmount(v.replace(',', '.'))}
|
||||
className="w-full flex-1 border-0 bg-transparent ps-1.5 font-semibold text-content outline-none dark:bg-transparent" style={fs(14, 'body')} />
|
||||
</div>
|
||||
@@ -1204,7 +1204,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const PERSON_LIST = 'divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card'
|
||||
const PERSON_ROW = 'grid min-h-[48px] grid-cols-[minmax(0,1fr)_130px] items-center gap-2.5 px-3 py-1.5'
|
||||
const AMOUNT_BOX = 'flex items-center gap-1 rounded-[8px] border border-edge bg-surface-input px-2.5'
|
||||
const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-end font-semibold text-content outline-none dark:bg-transparent'
|
||||
const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-end font-semibold text-content outline-none dark:bg-transparent placeholder:font-normal placeholder:text-content-faint'
|
||||
|
||||
// Who is in, shown as a real box to tick instead of a row that only fades.
|
||||
const tick = (on: boolean) => (
|
||||
@@ -1280,7 +1280,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{on ? (
|
||||
<div className={AMOUNT_BOX}>
|
||||
<span className="text-content-faint" style={fs(13, 'body')}>{sym(currency)}</span>
|
||||
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
|
||||
<NumericInput mode="signed-decimal" placeholder={zeroAmountInput(currency)} data-testid="payer-amount"
|
||||
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
|
||||
onValueChange={v => onPayerAmountChange(p.id, v)}
|
||||
className={AMOUNT_INPUT} style={fs(13.5, 'body')} />
|
||||
@@ -1346,7 +1346,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<span className="text-content-faint" style={fs(12)}>{sym(currency)}</span>
|
||||
<NumericInput
|
||||
mode="decimal"
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
placeholder={zeroAmountInput(currency)}
|
||||
value={localizeAmountInput(item.price, currency)}
|
||||
onValueChange={v => handleUpdateItemPrice(item.id, v)}
|
||||
className={AMOUNT_INPUT}
|
||||
@@ -1427,7 +1427,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
) : (
|
||||
<div className={AMOUNT_BOX}>
|
||||
<span className="text-content-faint" style={fs(13, 'body')}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder={localizeAmountInput((placeholderShares[p.id] || 0).toFixed(2), currency)} value={localizeAmountInput(customAmounts[p.id] || '', currency)}
|
||||
<input type="text" inputMode="decimal" placeholder={localizeAmountInput(amountToInputString(placeholderShares[p.id] || 0, currency), currency)} value={localizeAmountInput(customAmounts[p.id] || '', currency)}
|
||||
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
|
||||
className={AMOUNT_INPUT} style={fs(13.5, 'body')} />
|
||||
</div>
|
||||
@@ -1440,7 +1440,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{splitMode === 'equally' ? (
|
||||
participants.size > 0 && <>
|
||||
{badge(<><Users size={12} strokeWidth={2.4} />{participants.size}</>)}
|
||||
{badge(t('costs.perPerson', { amount: splitShareLabel(each, currency, fx, participants.size, base, null, locale) }))}
|
||||
{badge(t('costs.perPerson', { amount: splitShareLabel(each, currency, fx, participants.size, base, locale) }))}
|
||||
</>
|
||||
) : customBalanced ? (
|
||||
badge(<><Check size={12} strokeWidth={3} />{t('costs.splitBalanced')}</>, 'success')
|
||||
@@ -1557,7 +1557,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<EditorField label={t('costs.totalAmount')}>
|
||||
<div className="flex items-center rounded-[10px] border border-edge bg-surface-input px-3" style={{ height: FIELD_H, opacity: isTicketMode ? 0.6 : 1 }}>
|
||||
<span className="text-content-faint" style={fs(15, 'subtitle')}>{sym(currency)}</span>
|
||||
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
<NumericInput mode="signed-decimal" placeholder={zeroAmountInput(currency)} value={localizeAmountInput(isTicketMode ? amountToInputString(ticketInfo.total, currency) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
disabled={isTicketMode}
|
||||
|
||||
@@ -46,18 +46,21 @@ describe('expenseFxPreview', () => {
|
||||
})
|
||||
|
||||
describe('splitShareLabel', () => {
|
||||
const sym = (c: string) => (c === 'USD' ? '$' : c + ' ')
|
||||
|
||||
it('prints the entered share, then the share the row lends or borrows', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77')
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
|
||||
// Read in the trip currency, the share is the booked euros.
|
||||
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
})
|
||||
|
||||
it('prints the entered share alone when nothing is converted', () => {
|
||||
expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00')
|
||||
expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00')
|
||||
expect(splitShareLabel(125, 'USD', null, 2, 'USD', 'en-US')).toBe('$125.00')
|
||||
expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', 'en-US')).toBe('$0.00')
|
||||
})
|
||||
|
||||
it('prints a share the way the list prints its currency, grouped and with its own decimals (#2604)', () => {
|
||||
expect(splitShareLabel(15000, 'KRW', null, 4, 'KRW', 'en-US')).toBe('₩15,000')
|
||||
expect(splitShareLabel(1.5, 'KWD', null, 2, 'KWD', 'en-US')).toBe(formatMoney(1.5, 'KWD', 'en-US'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,10 +66,9 @@ export function splitShareLabel(
|
||||
fx: ExpenseFx | null,
|
||||
participants: number,
|
||||
base: string,
|
||||
sym: ((currency: string) => string) | null,
|
||||
locale: string,
|
||||
): string {
|
||||
const entered = sym ? sym(currency) + each.toFixed(2) : formatMoney(each, currency, locale)
|
||||
const entered = formatMoney(each, currency, locale)
|
||||
const whole = fx ? (fx.shown ?? fx.inTrip) : null
|
||||
if (whole == null || participants <= 0) return entered
|
||||
return `${entered} → ${formatMoney(whole / participants, base, locale)}`
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { BudgetItem, Trip } from '../../types';
|
||||
import { downloadBlob } from '../../utils/fileDownload';
|
||||
import { localToday } from '../Planner/today';
|
||||
import type { CostsSettlementResponse } from './costsModel';
|
||||
import { useCostsLedger } from './useCostsLedger';
|
||||
import { useFreezeMissingRates } from './useFreezeMissingRates';
|
||||
@@ -268,13 +269,19 @@ describe('useCostsLedger: writes', () => {
|
||||
expect(toastError).toHaveBeenCalledWith('common.unknownError');
|
||||
});
|
||||
|
||||
it('FE-BUDGET-LEDGER-011: settling one flow or all of them records transfers in the display currency', async () => {
|
||||
it('FE-BUDGET-LEDGER-011: settling one flow or all of them records transfers in the display currency, dated on the local day (#2604)', async () => {
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.settlement).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.settleFlow(1, 2, 15);
|
||||
});
|
||||
expect(createMock()).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 15, currency: 'EUR' });
|
||||
expect(createMock()).toHaveBeenCalledWith(7, {
|
||||
from_user_id: 1,
|
||||
to_user_id: 2,
|
||||
amount: 15,
|
||||
currency: 'EUR',
|
||||
settled_at: localToday(),
|
||||
});
|
||||
expect(settlementMock()).toHaveBeenCalledTimes(2);
|
||||
|
||||
createMock().mockClear();
|
||||
@@ -284,7 +291,13 @@ describe('useCostsLedger: writes', () => {
|
||||
await result.current.settleAll();
|
||||
});
|
||||
expect(createMock()).toHaveBeenCalledTimes(2);
|
||||
expect(createMock()).toHaveBeenNthCalledWith(2, 7, { from_user_id: 3, to_user_id: 1, amount: 5, currency: 'EUR' });
|
||||
expect(createMock()).toHaveBeenNthCalledWith(2, 7, {
|
||||
from_user_id: 3,
|
||||
to_user_id: 1,
|
||||
amount: 5,
|
||||
currency: 'EUR',
|
||||
settled_at: localToday(),
|
||||
});
|
||||
expect(toastError).toHaveBeenCalledWith('common.unknownError');
|
||||
expect(settlementMock()).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { TripStoreState } from '../../store/tripStore';
|
||||
import type { BudgetItem, Trip } from '../../types';
|
||||
import { downloadBlob } from '../../utils/fileDownload';
|
||||
import { localToday } from '../Planner/today';
|
||||
import {
|
||||
buildCostsCsv,
|
||||
categoryBreakdown,
|
||||
@@ -185,11 +186,16 @@ export function useCostsLedger({
|
||||
}
|
||||
};
|
||||
|
||||
// A quick settle is dated on the viewer's own day, as the payment dialog dates
|
||||
// it, so it is not left to the server's UTC clock (#2604).
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
try {
|
||||
await budgetApi.createSettlement(
|
||||
tripId,
|
||||
withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency)
|
||||
withFallbackFx(
|
||||
{ from_user_id: fromId, to_user_id: toId, amount, currency: base, settled_at: localToday() },
|
||||
tripCurrency
|
||||
)
|
||||
);
|
||||
loadSettlement();
|
||||
} catch {
|
||||
@@ -204,7 +210,13 @@ export function useCostsLedger({
|
||||
await budgetApi.createSettlement(
|
||||
tripId,
|
||||
withFallbackFx(
|
||||
{ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base },
|
||||
{
|
||||
from_user_id: f.from.user_id,
|
||||
to_user_id: f.to.user_id,
|
||||
amount: f.amount,
|
||||
currency: base,
|
||||
settled_at: localToday(),
|
||||
},
|
||||
tripCurrency
|
||||
)
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock('./receiptUploads', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-021
|
||||
// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-023
|
||||
|
||||
const PEOPLE: TripMember[] = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
@@ -268,13 +268,13 @@ describe('useExpenseForm: what it adds up', () => {
|
||||
expect(result.current.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-008: amounts are refused beyond the currency decimals and ticket prices cannot be negative', () => {
|
||||
it('FE-BUDGET-EXPFORM-008: amounts are cut to the currency decimals, text is refused and ticket prices cannot be negative', () => {
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.handleCustomAmountChange(1, '1.234');
|
||||
result.current.handleCustomAmountChange(2, 'abc');
|
||||
});
|
||||
expect(result.current.customAmounts).toEqual({});
|
||||
expect(result.current.customAmounts).toEqual({ 1: '1.23' });
|
||||
act(() => result.current.handleCustomAmountChange(1, '-2'));
|
||||
expect(result.current.customAmounts).toEqual({ 1: '-2' });
|
||||
act(() => result.current.handleCustomAmountChange(1, ''));
|
||||
@@ -566,4 +566,37 @@ describe('useExpenseForm: saving', () => {
|
||||
total_price: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-022: amounts take only the decimals the currency has, typed or switched to (#2604)', async () => {
|
||||
const { result } = setup({ prefill: { currency: 'KRW' } });
|
||||
act(() => {
|
||||
result.current.setName('Dinner');
|
||||
result.current.onTotalChange('1000.555');
|
||||
});
|
||||
// The point stays, so a digit typed after it is not taken as one more whole won.
|
||||
expect(result.current.total).toBe('1000.');
|
||||
act(() => result.current.onTotalChange('1000.5'));
|
||||
expect(result.current.total).toBe('1000.');
|
||||
act(() => {
|
||||
result.current.setSplitMode('custom');
|
||||
result.current.handleCustomAmountChange(1, '600,5');
|
||||
result.current.enableMultiPayer();
|
||||
});
|
||||
expect(result.current.customAmounts[1]).toBe('600.');
|
||||
act(() => result.current.onPayerAmountChange(1, '400.75'));
|
||||
expect(result.current.payerAmounts[1]).toBe('400.');
|
||||
|
||||
// Euros keep two places, and a switch to won drops the cents already typed.
|
||||
act(() => result.current.setCurrency('EUR'));
|
||||
act(() => result.current.onTotalChange('12.345'));
|
||||
expect(result.current.total).toBe('12.34');
|
||||
act(() => result.current.setCurrency('KRW'));
|
||||
expect(result.current.total).toBe('12');
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-023: a three-decimal currency keeps its third place', () => {
|
||||
const { result } = setup({ prefill: { currency: 'KWD' } });
|
||||
act(() => result.current.onTotalChange('1.5555'));
|
||||
expect(result.current.total).toBe('1.555');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,10 @@ import { SYMBOLS } from './BudgetPanel.constants';
|
||||
import type { TripMember } from './BudgetPanelMemberChips';
|
||||
import type { ExpensePrefill } from './CostsPanel';
|
||||
import {
|
||||
amountInCurrency,
|
||||
amountPattern,
|
||||
calculateTicketShares,
|
||||
clampAmountDecimals,
|
||||
hasTicketSplit,
|
||||
newExpenseSeed,
|
||||
payersBalanced,
|
||||
@@ -81,7 +83,7 @@ export function useExpenseForm({
|
||||
localToday()
|
||||
)
|
||||
);
|
||||
const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency);
|
||||
const [currency, setCurrencyState] = useState(editing ? editingCurrency : seed.currency);
|
||||
const [day, setDay] = useState(editing ? editing.expense_date || localToday() : seed.day);
|
||||
const [note, setNote] = useState(() => readUserNote(editing));
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
@@ -222,7 +224,23 @@ export function useExpenseForm({
|
||||
? ticketValid
|
||||
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced));
|
||||
|
||||
const onTotalChange = (v: string) => setTotal(v.replace(',', '.'));
|
||||
// Every amount field takes only the decimals its currency has (#2604): a won
|
||||
// amount typed as 1000.555 would otherwise be saved as typed, listed as ₩1,001
|
||||
// and reopened as 1001.
|
||||
const asAmount = (raw: string) => clampAmountDecimals(raw.replace(',', '.'), currency);
|
||||
|
||||
const onTotalChange = (v: string) => setTotal(asAmount(v));
|
||||
|
||||
// A switch to a currency with fewer decimals cuts what was already typed the same way.
|
||||
const setCurrency = (next: string) => {
|
||||
const cut = (m: Record<number, string>) =>
|
||||
Object.fromEntries(Object.entries(m).map(([id, v]) => [id, amountInCurrency(v, next)]));
|
||||
setCurrencyState(next);
|
||||
setTotal((prev) => amountInCurrency(prev, next));
|
||||
setPayerAmounts(cut);
|
||||
setCustomAmounts(cut);
|
||||
setTicketItems((prev) => prev.map((item) => ({ ...item, price: amountInCurrency(item.price, next) })));
|
||||
};
|
||||
|
||||
// Keep the payer amounts summing to the total as it changes, including in ticket
|
||||
// mode, where the total is derived from the ticket items rather than typed. Only
|
||||
@@ -266,7 +284,7 @@ export function useExpenseForm({
|
||||
};
|
||||
|
||||
const onPayerAmountChange = (id: number, v: string) => {
|
||||
const val = v.replace(',', '.');
|
||||
const val = asAmount(v);
|
||||
const nextPinned = new Set(pinnedPayers);
|
||||
nextPinned.add(id);
|
||||
setPinnedPayers(nextPinned);
|
||||
@@ -274,7 +292,7 @@ export function useExpenseForm({
|
||||
};
|
||||
|
||||
const handleCustomAmountChange = (id: number, raw: string) => {
|
||||
const val = raw.replace(',', '.');
|
||||
const val = asAmount(raw);
|
||||
if (val === '' || amountPattern(currency, true).test(val)) {
|
||||
setCustomAmounts((prev) => ({ ...prev, [id]: val }));
|
||||
}
|
||||
@@ -292,7 +310,7 @@ export function useExpenseForm({
|
||||
};
|
||||
|
||||
const handleUpdateItemPrice = (id: string, raw: string) => {
|
||||
const price = raw.replace(',', '.');
|
||||
const price = asAmount(raw);
|
||||
if (price === '' || amountPattern(currency, false).test(price)) {
|
||||
setTicketItems((prev) => prev.map((item) => (item.id === id ? { ...item, price } : item)));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { localToday } from '../Planner/today';
|
||||
import type { CostsSettlement } from './costsModel';
|
||||
import { useSettlementForm } from './useSettlementForm';
|
||||
|
||||
// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-009
|
||||
// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-010
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../api/client')>()),
|
||||
@@ -218,4 +218,14 @@ describe('useSettlementForm: saving', () => {
|
||||
expect(await run(true)).toBe(1);
|
||||
expect(await run(false)).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-SETTLEFORM-010: the amount takes only the decimals its currency has (#2604)', () => {
|
||||
const { result } = setup({ editing: null });
|
||||
act(() => result.current.setAmount('12,345'));
|
||||
expect(result.current.amount).toBe('12.34');
|
||||
act(() => result.current.setCurrency('JPY'));
|
||||
expect(result.current.amount).toBe('12');
|
||||
act(() => result.current.setAmount('1000.5'));
|
||||
expect(result.current.amount).toBe('1000.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { budgetApi } from '../../api/client';
|
||||
import { withFallbackFx } from '../../hooks/useExchangeRates';
|
||||
import { amountToInputString } from '../../utils/formatters';
|
||||
import { localToday } from '../Planner/today';
|
||||
import { settlementDate } from './CostsPanel.helpers';
|
||||
import { amountInCurrency, clampAmountDecimals, settlementDate } from './CostsPanel.helpers';
|
||||
import type { CostsSettlement } from './costsModel';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
@@ -96,15 +96,23 @@ export function useSettlementForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Typed with either decimal mark and cut to the currency's decimals (#2604), on
|
||||
// entry and when the currency changes under an amount already typed.
|
||||
const changeAmount = (v: string) => setAmount(clampAmountDecimals(v.replace(',', '.'), currency));
|
||||
const changeCurrency = (next: string) => {
|
||||
setCurrency(next);
|
||||
setAmount((prev) => amountInCurrency(prev, next));
|
||||
};
|
||||
|
||||
return {
|
||||
fromId,
|
||||
setFromId,
|
||||
toId,
|
||||
setToId,
|
||||
amount,
|
||||
setAmount,
|
||||
setAmount: changeAmount,
|
||||
currency,
|
||||
setCurrency,
|
||||
setCurrency: changeCurrency,
|
||||
day,
|
||||
setDay,
|
||||
note,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader }
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { formatMoney, localizeAmountInput } from '../../../../utils/formatters'
|
||||
import { amountToInputString, formatMoney, localizeAmountInput, zeroAmountInput } from '../../../../utils/formatters'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { splitShareLabel } from '../../../../components/Budget/expenseFx'
|
||||
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
@@ -150,11 +150,11 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<NumericInput
|
||||
mode="signed-decimal"
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
placeholder={zeroAmountInput(currency)}
|
||||
value={localizeAmountInput(isTicketMode ? amountToInputString(ticketInfo.total, currency) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
disabled={isTicketMode}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:text-m-faint"
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -276,10 +276,10 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<NumericInput
|
||||
mode="signed-decimal"
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
placeholder={zeroAmountInput(currency)}
|
||||
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
|
||||
onValueChange={v => onPayerAmountChange(p.id, v)}
|
||||
className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none"
|
||||
className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:font-normal placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -330,10 +330,10 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<span className="text-[0.75rem] text-m-faint">{sym(currency)}</span>
|
||||
<NumericInput
|
||||
mode="decimal"
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
placeholder={zeroAmountInput(currency)}
|
||||
value={localizeAmountInput(item.price, currency)}
|
||||
onValueChange={v => handleUpdateItemPrice(item.id, v)}
|
||||
className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none"
|
||||
className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:font-normal placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={() => handleRemoveItem(item.id)} className="flex-none text-m-muted" aria-label={t('common.delete')}>
|
||||
@@ -371,7 +371,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
{people.map(p => (
|
||||
<div key={p.id} className="flex justify-between text-[0.8125rem]">
|
||||
<span className="text-m-muted">{nameOf(p)}</span>
|
||||
<span className="font-semibold text-m-ink [font-variant-numeric:tabular-nums]">{sym(currency)}{(ticketInfo.shares[p.id] || 0).toFixed(2)}</span>
|
||||
<span className="font-semibold text-m-ink [font-variant-numeric:tabular-nums]">{formatMoney(ticketInfo.shares[p.id] || 0, currency, locale)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
{splitMode === 'equally' ? (
|
||||
on ? (
|
||||
<span className="flex-none pe-1 text-[0.8125rem] font-semibold text-m-ink [font-variant-numeric:tabular-nums]">
|
||||
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
|
||||
{formatMoney(equalShares[p.id] || 0, currency, locale)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-none pe-1 text-[0.6875rem] text-m-faint">{t('costs.tapToInclude')}</span>
|
||||
@@ -408,10 +408,10 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder={localizeAmountInput((placeholderShares[p.id] || 0).toFixed(2), currency)}
|
||||
placeholder={localizeAmountInput(amountToInputString(placeholderShares[p.id] || 0, currency), currency)}
|
||||
value={localizeAmountInput(customAmounts[p.id] || '', currency)}
|
||||
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
|
||||
className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
|
||||
className="w-full border-0 bg-transparent py-[7px] text-end text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:font-normal placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -426,13 +426,13 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<div className="mt-2 text-[0.71875rem]">
|
||||
{splitMode === 'equally' ? (
|
||||
<span className="text-m-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })}
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, locale) })}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`font-semibold ${customBalanced ? 'text-[color:var(--m-st-confirmed)]' : 'text-[color:var(--m-st-danger)]'}`}>
|
||||
{customBalanced
|
||||
? t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })
|
||||
: `${sym(currency)}${splitSum.toFixed(2)} / ${sym(currency)}${totalNum.toFixed(2)}`}
|
||||
? t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, locale) })
|
||||
: `${formatMoney(splitSum, currency, locale)} / ${formatMoney(totalNum, currency, locale)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { formatMoney } from '../../../../utils/formatters'
|
||||
import { formatMoney, localizeAmountInput, zeroAmountInput } from '../../../../utils/formatters'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import MCostSheet from '../sheets/MCostSheet'
|
||||
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
|
||||
@@ -175,7 +175,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.78125rem] font-bold text-m-ink">{t('costs.outstanding')}</div>
|
||||
<div className="font-geist text-[0.59375rem] text-m-faint"><b>{totals.outstandingCount}</b> {t('costs.outstandingItems')}</div>
|
||||
<div className="font-geist text-[0.59375rem] text-m-faint"><b>{totals.outstandingCount}</b> {t('costs.outstandingItems', { count: totals.outstandingCount })}</div>
|
||||
</div>
|
||||
<div className="flex-none font-geist text-[0.9375rem] font-extrabold tabular-nums" style={{ color: STATUS_COLOR.pending }}>
|
||||
{formatMoney(totals.outstanding, base, locale)}
|
||||
@@ -873,7 +873,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, p
|
||||
|
||||
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.amount')}</Eyebrow>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" inputMode="decimal" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0.00" className={FIELD_CLS} />
|
||||
<input type="text" inputMode="decimal" value={localizeAmountInput(amount, currency)} onChange={e => setAmount(e.target.value)} placeholder={zeroAmountInput(currency)} className={FIELD_CLS} />
|
||||
<span className="flex-none font-geist text-[0.75rem] font-bold text-m-faint">{currency}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, formatPriceText, currencyDecimals, localizeAmountInput, amountToInputString } from './formatters'
|
||||
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, formatPriceText, currencyDecimals, localizeAmountInput, amountToInputString, zeroAmountInput } from './formatters'
|
||||
import { CURRENCIES, SYMBOLS, currenciesWith } from '../components/Budget/BudgetPanel.constants'
|
||||
import type { Day } from '../types'
|
||||
|
||||
@@ -40,6 +40,15 @@ describe('localizeAmountInput (#1624)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('zeroAmountInput (#2604)', () => {
|
||||
it('shows an empty amount the way the currency writes it', () => {
|
||||
expect(zeroAmountInput('EUR')).toBe('0,00')
|
||||
expect(zeroAmountInput('USD')).toBe('0.00')
|
||||
expect(zeroAmountInput('KRW')).toBe('0')
|
||||
expect(zeroAmountInput('KWD')).toBe('0.000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('amountToInputString (#2175)', () => {
|
||||
it('pads a stored number to the currency decimals so the edit form matches the list', () => {
|
||||
expect(amountToInputString(4.9, 'EUR')).toBe('4.90')
|
||||
|
||||
@@ -115,6 +115,11 @@ export function amountToInputString(value: number | null | undefined, currency:
|
||||
return cleanAmount(value).toFixed(currencyDecimals(currency))
|
||||
}
|
||||
|
||||
/** An empty amount field's placeholder in the currency's own form: "0,00" in euros, "0" in won (#2604). */
|
||||
export function zeroAmountInput(currency: string): string {
|
||||
return localizeAmountInput(amountToInputString(0, currency), currency)
|
||||
}
|
||||
|
||||
/**
|
||||
* A price held as free text, tidied only when it really is a number. Import
|
||||
* writes whatever the confirmation said into this field, so anything that does
|
||||
|
||||
@@ -10,7 +10,7 @@ import { localToday } from '../../../../src/components/Planner/today'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030
|
||||
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-038
|
||||
// The sheet reads its copy from useTranslation(), so assertions are English.
|
||||
// EUR formats de-DE style: displayed amounts and inputs use a comma.
|
||||
|
||||
@@ -113,8 +113,9 @@ describe('MCostSheet', () => {
|
||||
// A comma is normalised to a dot before it reaches the state.
|
||||
fillBasics('Dinner', '85,50')
|
||||
|
||||
expect(screen.getAllByText('€42.75')).toHaveLength(2)
|
||||
expect(screen.getByText('Split 2 ways · €42.75 each')).toBeInTheDocument()
|
||||
// Shares read the way the list prints euros (#2604).
|
||||
expect(screen.getAllByText('42,75 €')).toHaveLength(2)
|
||||
expect(screen.getByText('Split 2 ways · 42,75 € each')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
@@ -208,7 +209,7 @@ describe('MCostSheet', () => {
|
||||
fillBasics('Taxi', '30')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
|
||||
expect(screen.getByText('1 person · €30.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 person · 30,00 €')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Tap to include')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(submit())
|
||||
@@ -346,12 +347,12 @@ describe('MCostSheet', () => {
|
||||
const shares = screen.getAllByPlaceholderText('50,00')
|
||||
expect(shares).toHaveLength(2)
|
||||
fireEvent.change(shares[0], { target: { value: '30' } })
|
||||
expect(screen.getByText('€30.00 / €100.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('30,00 € / 100,00 €')).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
|
||||
// The remaining share now suggests what is left of the total.
|
||||
fireEvent.change(screen.getByPlaceholderText('70,00'), { target: { value: '70' } })
|
||||
expect(screen.getByText('Split 2 ways · €50.00 each')).toBeInTheDocument()
|
||||
expect(screen.getByText('Split 2 ways · 50,00 € each')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
@@ -359,14 +360,14 @@ describe('MCostSheet', () => {
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-015: a custom share rejects a third decimal and is cleared when the member drops out', () => {
|
||||
it('FE-MOB-COSTSH-015: a custom share drops a third decimal and is cleared when the member drops out', () => {
|
||||
renderSheet()
|
||||
fillBasics('Dinner', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
||||
|
||||
const share = screen.getAllByPlaceholderText('50,00')[0]
|
||||
fireEvent.change(share, { target: { value: '30.123' } })
|
||||
expect(share).toHaveValue('')
|
||||
expect(share).toHaveValue('30,12')
|
||||
fireEvent.change(share, { target: { value: '30,5' } })
|
||||
expect(screen.getByDisplayValue('30,5')).toBe(share)
|
||||
|
||||
@@ -386,7 +387,7 @@ describe('MCostSheet', () => {
|
||||
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
|
||||
|
||||
expect(totalField()).toHaveValue('20,00')
|
||||
expect(screen.getAllByText('€10.00')).toHaveLength(2)
|
||||
expect(screen.getAllByText('10,00 €')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[1])
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
@@ -726,4 +727,23 @@ describe('MCostSheet', () => {
|
||||
expect(screen.getAllByText('XTS 5.00')).toHaveLength(2)
|
||||
expect(screen.getByText('Split 2 ways · XTS 5.00 each')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-038: a won expense reads whole won with grouping and suggests its custom shares faintly (#2604)', () => {
|
||||
localStorage.setItem('trek_fx_KRW', JSON.stringify({ rates: { KRW: 1 }, ts: Date.now() }))
|
||||
renderSheet({ base: 'KRW' })
|
||||
fireEvent.change(nameField(), { target: { value: 'BBQ' } })
|
||||
const total = screen.getByPlaceholderText('0')
|
||||
fireEvent.change(total, { target: { value: '60000.55' } })
|
||||
expect(total).toHaveValue('60000.')
|
||||
|
||||
expect(screen.getAllByText('₩30,000')).toHaveLength(2)
|
||||
expect(screen.getByText('Split 2 ways · ₩30,000 each')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
||||
const shares = screen.getAllByPlaceholderText('30000')
|
||||
expect(shares[0]).toHaveValue('')
|
||||
expect(shares[0].className).toContain('placeholder:text-m-faint')
|
||||
fireEvent.change(shares[0], { target: { value: '10000' } })
|
||||
expect(screen.getByText('₩10,000 / ₩60,000')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +63,11 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'مبلغ غير مسدَّد',
|
||||
'costs.outstandingSub': 'مصاريف بلا دافع بعد',
|
||||
'costs.outstandingItems': 'مصاريف بحاجة إلى دافع',
|
||||
'costs.outstandingItems.zero': 'مصاريف بحاجة إلى دافع',
|
||||
'costs.outstandingItems.one': 'مصروف بحاجة إلى دافع',
|
||||
'costs.outstandingItems.two': 'مصروفان بحاجة إلى دافع',
|
||||
'costs.outstandingItems.few': 'مصروفات بحاجة إلى دافع',
|
||||
'costs.outstandingItems.many': 'مصروفًا بحاجة إلى دافع',
|
||||
'costs.expenses': 'المصروفات',
|
||||
'costs.entries': '{count} إدخال',
|
||||
'costs.entries.zero': 'لا توجد إدخالات',
|
||||
|
||||
@@ -66,6 +66,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Gözləyən məbləğ',
|
||||
'costs.outstandingSub': 'Ödəyicisi hələ təyin edilməyən xərclər',
|
||||
'costs.outstandingItems': 'xərc üçün ödəyici təyin edilməlidir',
|
||||
'costs.outstandingItems.one': 'xərc üçün ödəyici təyin edilməlidir',
|
||||
|
||||
'costs.expenses': 'Xərclər',
|
||||
'costs.entries': '{count} qeyd',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Valor pendente',
|
||||
'costs.outstandingSub': 'Despesas sem pagador',
|
||||
'costs.outstandingItems': 'despesas sem pagador',
|
||||
'costs.outstandingItems.one': 'despesa sem pagador',
|
||||
'costs.expenses': 'Despesas',
|
||||
'costs.entries': '{count} lançamentos',
|
||||
'costs.entries.one': '{count} lançamento',
|
||||
|
||||
@@ -168,6 +168,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Import pendent',
|
||||
'costs.outstandingSub': 'Despeses encara sense pagador',
|
||||
'costs.outstandingItems': 'despeses necessiten un pagador',
|
||||
'costs.outstandingItems.one': 'despesa necessita un pagador',
|
||||
'costs.multiplePayers': 'Han pagat diverses persones',
|
||||
'costs.singlePayer': 'Ha pagat una sola persona',
|
||||
'costs.payersUnbalanced': 'Els imports dels pagadors han de sumar {amount}',
|
||||
|
||||
@@ -60,6 +60,8 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Nevyřízená částka',
|
||||
'costs.outstandingSub': 'Výdaje zatím bez plátce',
|
||||
'costs.outstandingItems': 'výdajů potřebuje plátce',
|
||||
'costs.outstandingItems.one': 'výdaj potřebuje plátce',
|
||||
'costs.outstandingItems.few': 'výdaje potřebují plátce',
|
||||
'costs.expenses': 'Výdaje',
|
||||
'costs.entries': '{count} položek',
|
||||
'costs.entries.one': '{count} položka',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Ausstehender Betrag',
|
||||
'costs.outstandingSub': 'Ausgaben noch ohne Zahler',
|
||||
'costs.outstandingItems': 'Ausgaben ohne Zahler',
|
||||
'costs.outstandingItems.one': 'Ausgabe ohne Zahler',
|
||||
'costs.expenses': 'Ausgaben',
|
||||
'costs.entries': '{count} Einträge',
|
||||
'costs.entries.one': '{count} Eintrag',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Outstanding amount',
|
||||
'costs.outstandingSub': 'Expenses with no payer yet',
|
||||
'costs.outstandingItems': 'expenses need a payer',
|
||||
'costs.outstandingItems.one': 'expense needs a payer',
|
||||
'costs.expenses': 'Expenses',
|
||||
'costs.entries': '{count} entries',
|
||||
'costs.entries.one': '{count} entry',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Importe pendiente',
|
||||
'costs.outstandingSub': 'Gastos sin pagador aún',
|
||||
'costs.outstandingItems': 'gastos sin pagador',
|
||||
'costs.outstandingItems.one': 'gasto sin pagador',
|
||||
'costs.expenses': 'Gastos',
|
||||
'costs.entries': '{count} entradas',
|
||||
'costs.entries.one': '{count} entrada',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Tasumata summa',
|
||||
'costs.outstandingSub': 'Kulud, millel veel maksja puudub',
|
||||
'costs.outstandingItems': 'kulu vajab maksjat',
|
||||
'costs.outstandingItems.one': 'kulu vajab maksjat',
|
||||
'costs.expenses': 'Kulud',
|
||||
'costs.entries': '{count} kirjet',
|
||||
'costs.entries.one': '{count} kirje',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Montant en attente',
|
||||
'costs.outstandingSub': 'Dépenses sans payeur',
|
||||
'costs.outstandingItems': 'dépenses sans payeur',
|
||||
'costs.outstandingItems.one': 'dépense sans payeur',
|
||||
'costs.expenses': 'Dépenses',
|
||||
'costs.entries': '{count} entrées',
|
||||
'costs.entries.one': '{count} entrée',
|
||||
|
||||
@@ -61,6 +61,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Εκκρεμές ποσό',
|
||||
'costs.outstandingSub': 'Έξοδα χωρίς πληρωτή ακόμη',
|
||||
'costs.outstandingItems': 'έξοδα χρειάζονται πληρωτή',
|
||||
'costs.outstandingItems.one': 'έξοδο χρειάζεται πληρωτή',
|
||||
'costs.expenses': 'Έξοδα',
|
||||
'costs.entries': '{count} εγγραφές',
|
||||
'costs.entries.one': '{count} εγγραφή',
|
||||
|
||||
@@ -61,6 +61,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Rendezetlen összeg',
|
||||
'costs.outstandingSub': 'Fizető nélküli költségek',
|
||||
'costs.outstandingItems': 'költség fizetőre vár',
|
||||
'costs.outstandingItems.one': 'költség fizetőre vár',
|
||||
'costs.expenses': 'Költségek',
|
||||
'costs.entries': '{count} bejegyzés',
|
||||
'costs.entries.one': '{count} bejegyzés',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Importo in sospeso',
|
||||
'costs.outstandingSub': 'Spese senza pagante',
|
||||
'costs.outstandingItems': 'spese senza pagante',
|
||||
'costs.outstandingItems.one': 'spesa senza pagante',
|
||||
'costs.expenses': 'Spese',
|
||||
'costs.entries': '{count} voci',
|
||||
'costs.entries.one': '{count} voce',
|
||||
|
||||
@@ -60,6 +60,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Openstaand bedrag',
|
||||
'costs.outstandingSub': 'Uitgaven zonder betaler',
|
||||
'costs.outstandingItems': 'uitgaven zonder betaler',
|
||||
'costs.outstandingItems.one': 'uitgave zonder betaler',
|
||||
'costs.expenses': 'Uitgaven',
|
||||
'costs.entries': '{count} invoeren',
|
||||
'costs.entries.one': '{count} invoer',
|
||||
|
||||
@@ -60,7 +60,10 @@ const budget: TranslationStrings = {
|
||||
'costs.youPaid': 'Zapłaciłeś',
|
||||
'costs.outstanding': 'Kwota nierozliczona',
|
||||
'costs.outstandingSub': 'Wydatki bez płatnika',
|
||||
'costs.outstandingItems': 'wydatków potrzebuje płatnika',
|
||||
'costs.outstandingItems': 'wydatku potrzebuje płatnika',
|
||||
'costs.outstandingItems.one': 'wydatek potrzebuje płatnika',
|
||||
'costs.outstandingItems.few': 'wydatki potrzebują płatnika',
|
||||
'costs.outstandingItems.many': 'wydatków potrzebuje płatnika',
|
||||
'costs.expenses': 'Wydatki',
|
||||
'costs.entries': '{count} wpisu',
|
||||
'costs.entries.one': '{count} wpis',
|
||||
|
||||
@@ -60,7 +60,10 @@ const budget: TranslationStrings = {
|
||||
'costs.youPaid': 'Вы заплатили',
|
||||
'costs.outstanding': 'Неурегулированная сумма',
|
||||
'costs.outstandingSub': 'Расходы без плательщика',
|
||||
'costs.outstandingItems': 'расходов без плательщика',
|
||||
'costs.outstandingItems': 'расхода без плательщика',
|
||||
'costs.outstandingItems.one': 'расход без плательщика',
|
||||
'costs.outstandingItems.few': 'расхода без плательщика',
|
||||
'costs.outstandingItems.many': 'расходов без плательщика',
|
||||
'costs.expenses': 'Расходы',
|
||||
'costs.entries': '{count} записи',
|
||||
'costs.entries.one': '{count} запись',
|
||||
|
||||
@@ -60,6 +60,8 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Nevyriešená suma',
|
||||
'costs.outstandingSub': 'Výdavky zatiaľ bez platcu',
|
||||
'costs.outstandingItems': 'výdavkov potrebuje platcu',
|
||||
'costs.outstandingItems.one': 'výdavok potrebuje platcu',
|
||||
'costs.outstandingItems.few': 'výdavky potrebujú platcu',
|
||||
'costs.expenses': 'Výdavky',
|
||||
'costs.entries': '{count} položiek',
|
||||
'costs.entries.one': '{count} položka',
|
||||
|
||||
@@ -59,6 +59,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Utestående belopp',
|
||||
'costs.outstandingSub': 'Utgifter utan betalare än',
|
||||
'costs.outstandingItems': 'utgifter saknar betalare',
|
||||
'costs.outstandingItems.one': 'utgift saknar betalare',
|
||||
'costs.expenses': 'Kostnader',
|
||||
'costs.entries': '{count} inlägg',
|
||||
'costs.entries.one': '{count} inlägg',
|
||||
|
||||
@@ -60,6 +60,7 @@ const budget: TranslationStrings = {
|
||||
'costs.outstanding': 'Bekleyen tutar',
|
||||
'costs.outstandingSub': 'Henüz ödeyeni olmayan harcamalar',
|
||||
'costs.outstandingItems': 'harcamanın ödeyeni yok',
|
||||
'costs.outstandingItems.one': 'harcamanın ödeyeni yok',
|
||||
'costs.expenses': 'Harcamalar',
|
||||
'costs.entries': '{count} kayıt',
|
||||
'costs.entries.one': '{count} kayıt',
|
||||
|
||||
@@ -60,7 +60,10 @@ const budget: TranslationStrings = {
|
||||
'costs.youPaid': 'Ви заплатили',
|
||||
'costs.outstanding': 'Неврегульована сума',
|
||||
'costs.outstandingSub': 'Витрати без платника',
|
||||
'costs.outstandingItems': 'витрат без платника',
|
||||
'costs.outstandingItems': 'витрати без платника',
|
||||
'costs.outstandingItems.one': 'витрата без платника',
|
||||
'costs.outstandingItems.few': 'витрати без платника',
|
||||
'costs.outstandingItems.many': 'витрат без платника',
|
||||
'costs.expenses': 'Витрати',
|
||||
'costs.entries': '{count} запису',
|
||||
'costs.entries.one': '{count} запис',
|
||||
|
||||
Reference in New Issue
Block a user