Files
TREK/client/tests/unit/mobile/trip/costsModel.test.ts
T
Maurice 06bee95ae3 fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 04:22:50 +00: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/mobile/screens/trip/tabs/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;');
});
});