Files
TREK/client/tests/unit/mobile/trip/costsModel.test.ts
T
Maurice 131d57b755 refactor(budget): share the costs ledger and settlement form between desktop and phone
costsModel moves from the phone tabs to components/Budget.
useCostsLedger holds the settlement, the totals, the filters, the CSV
export and the payments, and useSettlementForm holds the payment form.
CostsPanel and MCostsTab both run on them.

The old differences stay behind flags. The desktop also loads the
settlement on a trip change, leaves expenses nobody paid out of my share
(#2225) and writes each currency as stored in the CSV. The phone sheet
starts blank, refills when it opens and ignores a second save while one
is running.
2026-10-09 15:19:42 +02:00

603 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
baseTotal,
buildCostsCsv,
categoryBreakdown,
categoryFilterKeys,
computeTotals,
currencyOf,
dayFilterKeys,
filterBudgetItems,
filterSettlements,
groupByDay,
groupLedgerByDay,
isUnfinished,
lineOf,
memberShareOf,
myPaidOf,
myShareOf,
tint,
type CostsCtx,
type CostsFilterState,
type CostsSettlement,
type CostsSettlementFlow,
} from '../../../../src/components/Budget/costsModel';
import { buildBudgetItem } from '../../../helpers/factories';
import type { BudgetItem } from '../../../../src/types';
// FE-MOB-CMOD-001 to FE-MOB-CMOD-027
const RATES: Record<string, number> = { EUR: 1, USD: 0.5, JPY: 0.01 };
const ctx: CostsCtx = {
me: 1,
// lower case on purpose — currencyOf has to normalise it
tripCurrency: 'eur',
displayCurrency: 'EUR',
convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1),
};
type Member = NonNullable<BudgetItem['members']>[number];
type Payer = NonNullable<BudgetItem['payers']>[number];
function member(user_id: number, amount?: number | null): Member {
return { user_id, paid: 0, username: `u${user_id}`, ...(amount === undefined ? {} : { amount }) };
}
function payer(user_id: number, amount: number): Payer {
return { user_id, amount, username: `u${user_id}` };
}
function settlement(overrides: Partial<CostsSettlement> = {}): CostsSettlement {
return { id: 1, from_user_id: 1, to_user_id: 2, amount: 10, currency: null, created_at: '2026-07-01T10:00:00Z', ...overrides };
}
function expense(overrides: Partial<BudgetItem> = {}): BudgetItem {
return buildBudgetItem({ category: 'other', ...overrides });
}
function filters(overrides: Partial<CostsFilterState> = {}): CostsFilterState {
return { search: '', segment: 'all', categoryKey: '', dayKey: '', ...overrides };
}
describe('costsModel — money maths', () => {
it('FE-MOB-CMOD-001: uppercases the expense currency and falls back to the trip currency', () => {
expect(currencyOf(expense({ currency: 'usd' }), ctx)).toBe('USD');
expect(currencyOf(expense({ currency: null }), ctx)).toBe('EUR');
expect(currencyOf(expense({ currency: '' }), ctx)).toBe('EUR');
});
it('FE-MOB-CMOD-002: converts the expense total into the base currency', () => {
expect(baseTotal(expense({ total_price: 100, currency: 'USD' }), ctx)).toBe(50);
expect(baseTotal(expense({ total_price: 100, currency: null }), ctx)).toBe(100);
expect(baseTotal(expense({ total_price: 0 }), ctx)).toBe(0);
});
it('FE-MOB-CMOD-003: myPaidOf only sums my own payer rows, converted', () => {
const e = expense({ total_price: 100, currency: 'USD', payers: [payer(1, 60), payer(2, 40)] });
expect(myPaidOf(e, ctx)).toBe(30);
expect(myPaidOf(expense({ payers: [payer(2, 40)] }), ctx)).toBe(0);
expect(myPaidOf(expense({ payers: undefined }), ctx)).toBe(0);
});
it('FE-MOB-CMOD-004: memberShareOf prefers an explicit custom amount', () => {
const e = expense({
total_price: 100,
currency: 'USD',
members: [member(1, 70), member(2, 30)],
});
expect(memberShareOf(e, 1, ctx)).toBe(35);
expect(memberShareOf(e, 2, ctx)).toBe(15);
});
it('FE-MOB-CMOD-005: an explicit zero share is honoured instead of falling back to the equal split', () => {
const e = expense({ id: 3, total_price: 100, members: [member(1, 0), member(2, 100)] });
expect(memberShareOf(e, 1, ctx)).toBe(0);
});
it('FE-MOB-CMOD-006: memberShareOf falls back to the rotating equal split', () => {
// id 3 % 3 members = rotation index 0, so user 1 carries the leftover cent
const e = expense({ id: 3, total_price: 100, members: [member(1), member(2), member(3)] });
expect(memberShareOf(e, 1, ctx)).toBe(33.34);
expect(memberShareOf(e, 2, ctx)).toBe(33.33);
expect(memberShareOf(e, 3, ctx)).toBe(33.33);
});
it('FE-MOB-CMOD-025: a zero-total expense splits into zero shares', () => {
const e = expense({ id: 2, total_price: 0, members: [member(1), member(2)] });
expect(memberShareOf(e, 1, ctx)).toBe(0);
});
it('FE-MOB-CMOD-007: a non-participant has no share', () => {
const e = expense({ total_price: 100, members: [member(2), member(3)] });
expect(memberShareOf(e, 1, ctx)).toBe(0);
expect(memberShareOf(expense({ members: undefined }), 1, ctx)).toBe(0);
});
it('FE-MOB-CMOD-008: myShareOf resolves the share of ctx.me', () => {
const e = expense({ id: 4, total_price: 80, currency: 'USD', members: [member(1), member(2)] });
expect(myShareOf(e, ctx)).toBe(20);
expect(myShareOf(e, ctx)).toBe(memberShareOf(e, 1, ctx));
});
it('FE-MOB-CMOD-009: isUnfinished flags totals nobody has actually paid', () => {
expect(isUnfinished(expense({ total_price: 100, payers: [] }), ctx)).toBe(true);
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 0)] }), ctx)).toBe(true);
expect(isUnfinished(expense({ total_price: 100, payers: undefined }), ctx)).toBe(true);
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 100)] }), ctx)).toBe(false);
expect(isUnfinished(expense({ total_price: 0, payers: [] }), ctx)).toBe(false);
});
it('FE-MOB-CMOD-030: isUnfinished flags a payer-less refund the same way (#2176)', () => {
expect(isUnfinished(expense({ total_price: -100, payers: [] }), ctx)).toBe(true);
// A negative payer is the refund's recipient — the entry is settled.
expect(isUnfinished(expense({ total_price: -100, payers: [payer(1, -100)] }), ctx)).toBe(false);
});
});
describe('costsModel — computeTotals', () => {
it('FE-MOB-CMOD-010: aggregates spend, my paid/share, settlement direction and outstanding', () => {
const paid = expense({
id: 10,
total_price: 100,
payers: [payer(1, 100)],
members: [member(1), member(2)],
});
const unpaid = expense({
id: 11,
total_price: 50,
currency: 'USD',
payers: [],
members: [member(2, 50)],
});
const flows: CostsSettlementFlow[] = [
{ from: { user_id: 1, username: 'me' }, to: { user_id: 2, username: 'ada' }, amount: 20 },
{ from: { user_id: 3, username: 'bo' }, to: { user_id: 1, username: 'me' }, amount: 5 },
{ from: { user_id: 2, username: 'ada' }, to: { user_id: 3, username: 'bo' }, amount: 7 },
];
expect(computeTotals([paid, unpaid], flows, ctx)).toEqual({
totalSpend: 125,
myPaid: 100,
myShare: 50,
owe: 20,
owed: 5,
outstanding: 25,
outstandingCount: 1,
});
});
it('FE-MOB-CMOD-011: returns zeroes for an empty trip', () => {
expect(computeTotals([], [], ctx)).toEqual({
totalSpend: 0,
myPaid: 0,
myShare: 0,
owe: 0,
owed: 0,
outstanding: 0,
outstandingCount: 0,
});
});
});
describe('costsModel — list filters and grouping', () => {
// e1: I fronted it and am net owed. e2: someone else paid. e3: I paid exactly my own share.
const e1 = expense({
id: 20,
name: 'Hotel Berlin',
category: 'accommodation',
expense_date: '2026-07-01',
total_price: 100,
payers: [payer(1, 100)],
members: [member(1), member(2)],
});
const e2 = expense({
id: 21,
name: 'Sushi',
category: 'food',
expense_date: '2026-07-02',
total_price: 60,
payers: [payer(2, 60)],
members: [member(1), member(2)],
});
const e3 = expense({
id: 22,
name: 'Museum ticket',
category: 'activities',
expense_date: null,
total_price: 30,
payers: [payer(1, 30)],
members: [member(1)],
});
const all = [e1, e2, e3];
it('FE-MOB-CMOD-012: "all" keeps every expense and does not mutate the input', () => {
const result = filterBudgetItems(all, filters(), ctx);
expect(result).toEqual(all);
expect(result).not.toBe(all);
});
it('FE-MOB-CMOD-013: "mine" keeps expenses I fronted money on', () => {
expect(filterBudgetItems(all, filters({ segment: 'mine' }), ctx).map(e => e.id)).toEqual([20, 22]);
});
it('FE-MOB-CMOD-014: "owed" keeps expenses where I am net owed', () => {
expect(filterBudgetItems(all, filters({ segment: 'owed' }), ctx).map(e => e.id)).toEqual([20]);
});
it('FE-MOB-CMOD-015: category, day and search narrow the list and combine', () => {
expect(filterBudgetItems(all, filters({ categoryKey: 'food' }), ctx).map(e => e.id)).toEqual([21]);
expect(filterBudgetItems(all, filters({ dayKey: '2026-07-02' }), ctx).map(e => e.id)).toEqual([21]);
// search is trimmed and case-insensitive
expect(filterBudgetItems(all, filters({ search: ' HOTEL ' }), ctx).map(e => e.id)).toEqual([20]);
expect(filterBudgetItems(all, filters({ search: 'nothing' }), ctx)).toEqual([]);
expect(
filterBudgetItems(all, filters({ segment: 'mine', categoryKey: 'accommodation' }), ctx).map(e => e.id),
).toEqual([20]);
});
it('FE-MOB-CMOD-016: groupByDay sorts newest first and sinks the no-date bucket', () => {
const a = expense({ id: 30, expense_date: '2026-07-01' });
const b = expense({ id: 31, expense_date: '2026-07-03' });
const c = expense({ id: 32, expense_date: null });
const d = expense({ id: 33, expense_date: '2026-07-01' });
expect(groupByDay([a, b, c, d])).toEqual([
{ dateKey: '2026-07-03', items: [b] },
{ dateKey: '2026-07-01', items: [a, d] },
{ dateKey: '', items: [c] },
]);
expect(groupByDay([])).toEqual([]);
});
it('FE-MOB-CMOD-026: groupByDay sinks the no-date bucket even when it comes first', () => {
const undated = expense({ id: 34, expense_date: null });
const mid = expense({ id: 35, expense_date: '2026-07-02' });
const late = expense({ id: 36, expense_date: '2026-07-04' });
expect(groupByDay([undated, mid, late]).map(g => g.dateKey)).toEqual(['2026-07-04', '2026-07-02', '']);
});
it('FE-MOB-CMOD-017: categoryFilterKeys lists only used categories, in canonical order', () => {
const items = [
expense({ category: 'food' }),
expense({ category: 'accommodation' }),
// legacy free-text value that catMeta folds into "flights"
expense({ category: 'Flight' }),
expense({ category: 'food' }),
];
expect(categoryFilterKeys(items)).toEqual(['accommodation', 'food', 'flights']);
expect(categoryFilterKeys([])).toEqual([]);
});
it('FE-MOB-CMOD-018: dayFilterKeys returns distinct dates ascending, ignoring undated rows', () => {
const items = [
expense({ expense_date: '2026-07-03' }),
expense({ expense_date: null }),
expense({ expense_date: '2026-07-01' }),
expense({ expense_date: '2026-07-01' }),
];
expect(dayFilterKeys(items)).toEqual(['2026-07-01', '2026-07-03']);
});
it('FE-MOB-CMOD-029: filterSettlements hides settlements when a search or category filter is active — they have neither', () => {
const list = [settlement()];
expect(filterSettlements(list, filters({ search: 'x' }), 1)).toEqual([]);
expect(filterSettlements(list, filters({ categoryKey: 'food' }), 1)).toEqual([]);
});
it('FE-MOB-CMOD-030: filterSettlements excludes them under "owed" and keeps only mine under "mine"', () => {
const mine = settlement({ id: 1, from_user_id: 1, to_user_id: 2 });
const others = settlement({ id: 2, from_user_id: 2, to_user_id: 3 });
const list = [mine, others];
expect(filterSettlements(list, filters({ segment: 'owed' }), 1)).toEqual([]);
expect(filterSettlements(list, filters({ segment: 'mine' }), 1).map(s => s.id)).toEqual([1]);
expect(filterSettlements(list, filters({ segment: 'all' }), 1).map(s => s.id)).toEqual([1, 2]);
});
it('FE-MOB-CMOD-031: filterSettlements narrows by the recorded day', () => {
const a = settlement({ id: 1, created_at: '2026-07-01T09:00:00Z' });
const b = settlement({ id: 2, created_at: '2026-07-02T09:00:00Z' });
expect(filterSettlements([a, b], filters({ dayKey: '2026-07-02' }), 1).map(s => s.id)).toEqual([2]);
});
it('FE-MOB-CMOD-036: filterSettlements narrows by settled_at when set, over the day it was recorded', () => {
const a = settlement({ id: 1, settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' });
expect(filterSettlements([a], filters({ dayKey: '2026-07-05' }), 1).map(s => s.id)).toEqual([1]);
expect(filterSettlements([a], filters({ dayKey: '2026-07-01' }), 1)).toEqual([]);
});
});
describe('costsModel: a bill entered in the display currency (#2525)', () => {
// A euro trip read in dollars, converted with the euro's own quote the way
// useExchangeRates(base, tripCurrency) converts: one euro buys 1.1551 dollars today.
const usdCtx: CostsCtx = {
me: 1,
tripCurrency: 'EUR',
displayCurrency: 'usd',
convert: (amount, currency) => {
const perEuro = ({ USD: 1.1551, EUR: 1, SEK: 10 } as Record<string, number>)[(currency || 'USD').toUpperCase()] ?? 1
return (amount / perEuro) * 1.1551
},
};
const hotel = (rate = 1.17) => expense({
total_price: 801.76, currency: 'USD', exchange_rate: rate,
payers: [payer(1, 801.76)], members: [member(1), member(2)],
});
it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since counts as its euros at today\'s rate', () => {
// 685.26 EUR, which the balances net and settle-up offers; the row, the payer's
// figure and the share read the same, or a payback of the row's half reopens the trip.
expect(baseTotal(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
expect(myPaidOf(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
expect(myShareOf(hotel(), usdCtx)).toBeCloseTo(395.77, 2);
// Under it, what was entered and the euros it was booked at, no third step.
const line = lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))!;
expect(line.entered).toEqual({ amount: 801.76, currency: 'USD' });
expect(line.into.currency).toBe('EUR');
expect(line.into.amount).toBeCloseTo(685.26, 2);
});
it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => {
const big = expense({ total_price: 12345.67, currency: 'USD', exchange_rate: 1.1551, payers: [payer(1, 12345.67)], members: [member(1), member(2)] });
const unpaid = expense({ total_price: 250, currency: 'USD', exchange_rate: 1.1551 });
expect(Math.round(baseTotal(big, usdCtx) * 100)).toBe(1234567);
expect(Math.round(baseTotal(unpaid, usdCtx) * 100)).toBe(25000);
expect(lineOf(12345.67, big, usdCtx, baseTotal(big, usdCtx))).toBeNull();
const t = computeTotals([big, unpaid], [], usdCtx);
expect(Math.round(t.totalSpend * 100)).toBe(1259567);
expect(Math.round(t.outstanding * 100)).toBe(25000);
});
it('FE-MOB-CMOD-040: other currencies go through the trip currency at their booked rate', () => {
// 1000 SEK booked at 10 per euro is 100 euro, and 100 euro is 115.51 dollars today.
const sek = expense({ total_price: 1000, currency: 'SEK', exchange_rate: 10 })
expect(baseTotal(sek, usdCtx)).toBeCloseTo(115.51, 2);
expect(lineOf(1000, sek, usdCtx, baseTotal(sek, usdCtx))).toEqual({ entered: { amount: 1000, currency: 'SEK' }, into: { amount: 100, currency: 'EUR' } });
// A row in the trip currency goes straight to the dollars.
const eur = expense({ total_price: 100, currency: null })
expect(baseTotal(eur, usdCtx)).toBeCloseTo(115.51, 2);
expect(lineOf(100, eur, usdCtx, 115.51)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 115.51, currency: 'USD' } });
});
it('FE-MOB-CMOD-041: there is nothing to explain when nothing was converted', () => {
// An amount of 0, a row written before the freeze, and any row when the viewer
// reads the trip currency itself.
const zero = expense({ total_price: 0, currency: 'USD', exchange_rate: 1.17 });
expect(lineOf(0, zero, usdCtx, 0)).toBeNull();
const legacy = expense({ total_price: 50, currency: 'USD', exchange_rate: 1 });
expect(baseTotal(legacy, usdCtx)).toBe(50);
expect(lineOf(50, legacy, usdCtx, 50)).toBeNull();
const inEur = { ...usdCtx, displayCurrency: 'EUR', convert: (a: number) => a };
expect(lineOf(801.76, hotel(), inEur, 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } });
});
it('FE-MOB-CMOD-042: the CSV adds what each row counts as in the trip currency', () => {
const t = ((key: string) => key) as unknown as Parameters<typeof buildCostsCsv>[1]['t'];
const items = [
hotel(),
expense({ name: 'Old taxi', total_price: 115.51, currency: 'USD', exchange_rate: 1, expense_date: '2026-07-20' }),
].map((e, i) => ({ ...e, name: e.name || 'Hotel', expense_date: e.expense_date || '2026-07-19', id: i + 1 }));
const inUsd = buildCostsCsv(items, { base: 'USD', ctx: usdCtx, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
expect(inUsd[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note');
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/);
// Never booked, so counted at today's rate, as the server counts it.
expect(inUsd[2]).toMatch(/;115\.51;USD;100\.00;115\.51;$/);
const inEur = buildCostsCsv(items, { base: 'EUR', ctx: { ...usdCtx, displayCurrency: 'EUR' }, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
expect(inEur[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note');
});
});
describe('costsModel — ledger grouping (expenses + settlement payments)', () => {
it('FE-MOB-CMOD-032: merges expenses and payments into shared day groups, newest first, no-date last', () => {
const e = expense({ id: 50, expense_date: '2026-07-02' });
const undatedExpense = expense({ id: 51, expense_date: null });
const s = settlement({ id: 1, created_at: '2026-07-03T12:00:00Z' });
const groups = groupLedgerByDay([e, undatedExpense], [s]);
expect(groups.map(g => g.dateKey)).toEqual(['2026-07-03', '2026-07-02', '']);
expect(groups[0].entries).toEqual([{ kind: 'payment', date: '2026-07-03', settlement: s }]);
expect(groups[1].entries).toEqual([{ kind: 'expense', date: '2026-07-02', item: e }]);
expect(groups[2].entries).toEqual([{ kind: 'expense', date: '', item: undatedExpense }]);
});
it('FE-MOB-CMOD-033: a payment recorded the same day as an expense lands in that same group', () => {
const e = expense({ id: 52, expense_date: '2026-07-05' });
const s = settlement({ id: 2, created_at: '2026-07-05T08:00:00Z' });
expect(groupLedgerByDay([e], [s])).toEqual([{
dateKey: '2026-07-05',
entries: [
{ kind: 'expense', date: '2026-07-05', item: e },
{ kind: 'payment', date: '2026-07-05', settlement: s },
],
}]);
});
it('FE-MOB-CMOD-034: a payment on a day with no expense still creates its own day group', () => {
const s = settlement({ id: 3, created_at: '2026-07-09T08:00:00Z' });
expect(groupLedgerByDay([], [s])).toEqual([{ dateKey: '2026-07-09', entries: [{ kind: 'payment', date: '2026-07-09', settlement: s }] }]);
});
it('FE-MOB-CMOD-037: a payment groups by its own settled_at, not the day it was recorded', () => {
const s = settlement({ id: 4, settled_at: '2026-07-11', created_at: '2026-07-09T08:00:00Z' });
expect(groupLedgerByDay([], [s])).toEqual([{ dateKey: '2026-07-11', entries: [{ kind: 'payment', date: '2026-07-11', settlement: s }] }]);
});
it('FE-MOB-CMOD-035: empty inputs produce no groups', () => {
expect(groupLedgerByDay([], [])).toEqual([]);
});
});
describe('costsModel — breakdown and presentation', () => {
it('FE-MOB-CMOD-019: categoryBreakdown sorts by amount and scales the bars against the largest', () => {
const items = [
expense({ category: 'accommodation', total_price: 300 }),
expense({ category: 'food', total_price: 100 }),
expense({ category: 'food', total_price: 200, currency: 'USD' }),
expense({ category: 'transport', total_price: 0 }),
];
const bars = categoryBreakdown(items, ctx);
expect(bars.map(b => b.key)).toEqual(['accommodation', 'food']);
expect(bars[0]).toEqual({ key: 'accommodation', amount: 300, widthPct: 100 });
expect(bars[1].amount).toBe(200);
expect(bars[1].widthPct).toBeCloseTo(66.667, 3);
});
it('FE-MOB-CMOD-020: categoryBreakdown drops categories without spend', () => {
expect(categoryBreakdown([], ctx)).toEqual([]);
expect(categoryBreakdown([expense({ category: 'food', total_price: 0 })], ctx)).toEqual([]);
});
it('FE-MOB-CMOD-031: categoryBreakdown nets refunds and keeps a net-negative category bar-less (#2176)', () => {
const items = [
expense({ category: 'food', total_price: 90 }),
expense({ category: 'food', total_price: -30 }),
expense({ category: 'activities', total_price: -20 }),
];
const bars = categoryBreakdown(items, ctx);
// Food nets 90 − 30 = 60 and carries the (full) bar; the fully refunded
// category keeps its own row at the bottom, without a bar — a negative CSS
// width would be dropped and paint a full one.
expect(bars).toEqual([
{ key: 'food', amount: 60, widthPct: 100 },
{ key: 'activities', amount: -20, widthPct: 0 },
]);
});
it('FE-MOB-CMOD-032: an all-negative breakdown renders rows but no bars (#2176)', () => {
const bars = categoryBreakdown([expense({ category: 'food', total_price: -20 })], ctx);
expect(bars).toEqual([{ key: 'food', amount: -20, widthPct: 0 }]);
});
it('FE-MOB-CMOD-021: tint turns a hex colour into rgba, with or without the leading hash', () => {
expect(tint('#8B5CF6', 0.3)).toBe('rgba(139, 92, 246, 0.3)');
expect(tint('000000', 1)).toBe('rgba(0, 0, 0, 1)');
});
});
describe('costsModel — CSV export', () => {
const t = (key: string) => key;
it('FE-MOB-CMOD-022: builds a semicolon CSV sorted by date with per-currency decimals', () => {
const items = [
expense({
id: 40,
name: 'Ryokan',
category: 'accommodation',
expense_date: '2026-07-16',
total_price: 1500,
currency: 'JPY',
note: 'paid cash',
}),
expense({
id: 41,
name: 'Coffee',
category: 'food',
expense_date: '2026-07-15',
total_price: 4.5,
currency: null,
note: null,
}),
];
const { filename, content } = buildCostsCsv(items, {
base: 'EUR',
ctx,
locale: 'en-US',
tripTitle: 'Trip: Tokyo/2026!',
t,
});
const rows = content.split('\r\n');
expect(rows[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note');
expect(rows[1]).toBe('07/15/2026;Coffee;costs.cat.food;4.50;EUR;4.50;');
// JPY is a zero-decimal currency, the base column stays at 2
expect(rows[2]).toBe('07/16/2026;Ryokan;costs.cat.accommodation;1500;JPY;15.00;paid cash');
expect(filename).toBe('costs-Trip Tokyo2026.csv');
});
it('FE-MOB-CMOD-023: escapes separators, quotes and newlines, and drops ticket JSON notes', () => {
const items = [
expense({
id: 42,
name: 'Dinner; drinks',
category: 'food',
expense_date: '',
total_price: 20,
note: 'say "hi"\nlater',
}),
expense({
id: 43,
name: 'Tickets',
category: 'activities',
expense_date: null,
total_price: 10,
note: 'TICKETJSON:{"items":[]}',
}),
];
const { content, filename } = buildCostsCsv(items, {
base: 'EUR',
ctx,
locale: 'en-US',
tripTitle: null,
t,
});
const rows = content.split('\r\n');
expect(rows[1]).toContain('"Dinner; drinks"');
expect(rows[1]).toContain('"say ""hi""\nlater"');
// undated rows keep an empty date cell
expect(rows[1].startsWith(';')).toBe(true);
expect(rows[2].endsWith(';')).toBe(true);
expect(filename).toBe('costs-trip.csv');
});
it('FE-MOB-CMOD-028: prefixes formula-leading names and notes so spreadsheets keep them as text', () => {
const items = [
expense({
id: 46,
name: '=HYPERLINK("http://evil","click")',
category: 'food',
expense_date: null,
total_price: 5,
note: '@SUM(A1:A9)',
}),
expense({ id: 47, name: '-12 refund', category: 'other', expense_date: null, total_price: 5 }),
];
const { content } = buildCostsCsv(items, { base: 'EUR', ctx, locale: 'en-US', tripTitle: null, t });
const rows = content.split('\r\n');
// The quote inside the name still forces the field to be quoted around the apostrophe.
expect(rows[1]).toContain(`"'=HYPERLINK(""http://evil"",""click"")"`);
expect(rows[1]).toContain("'@SUM(A1:A9)");
expect(rows[2]).toContain("'-12 refund");
// Numbers never go through the escaper, so the amount columns stay parseable.
expect(rows[2]).toContain(';5.00;EUR;5.00;');
});
it('FE-MOB-CMOD-024: falls back to the raw ISO date when the locale is unusable', () => {
const { content } = buildCostsCsv(
[expense({ id: 44, name: 'Bus', category: 'transport', expense_date: '2026-07-16', total_price: 3 })],
{ base: 'EUR', ctx, locale: '!!', t },
);
expect(content.split('\r\n')[1].startsWith('2026-07-16;Bus;')).toBe(true);
});
it('FE-MOB-CMOD-027: an expense without a recorded total exports as zero', () => {
const bare = expense({ id: 45, name: 'Placeholder', category: 'other', expense_date: null });
const { content } = buildCostsCsv([{ ...bare, total_price: undefined } as unknown as BudgetItem], {
base: 'EUR',
ctx,
locale: 'en-US',
t,
});
expect(content.split('\r\n')[1]).toBe(';Placeholder;costs.cat.other;0.00;EUR;0.00;');
});
});