diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index 093ee2ae6..bd655873f 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -262,47 +262,51 @@ describe('paidByUser', () => { }) describe('finalBudgetFor', () => { - const finals = [{ user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50 }] + const finals = [{ + user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50, + sources: { fronted: [{ item_id: 1, cents: 10000 }], moved: [], outstanding: [] }, + }] it("returns the server's row for a participant in the ledger", () => { expect(finalBudgetFor(finals, { id: 1, username: 'alice' })).toBe(finals[0]) }) - it('reads a participant the ledger left out as costing nothing', () => { - expect(finalBudgetFor(finals, { id: 2, username: 'bob' })) - .toEqual({ user_id: 2, username: 'bob', avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0 }) + it('reads a participant the ledger left out as costing nothing, with nothing behind it', () => { + expect(finalBudgetFor(finals, { id: 2, username: 'bob' })).toEqual({ + user_id: 2, username: 'bob', avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0, + sources: { fronted: [], moved: [], outstanding: [] }, + }) }) }) describe('finalBudgetSources', () => { - const items = [ - { id: 1, payers: [{ user_id: 1, amount: 60 }] }, - { id: 2, payers: [{ user_id: 2, amount: 40 }] }, - { id: 3, payers: [{ user_id: 1, amount: -10 }] }, - ] - const settlements = [ - { id: 1, from_user_id: 2, to_user_id: 1 }, - { id: 2, from_user_id: 1, to_user_id: 3 }, - { id: 3, from_user_id: 2, to_user_id: 3 }, - ] - const flows = [ - { from: { user_id: 3 }, to: { user_id: 1 }, amount: 5 }, - { from: { user_id: 3 }, to: { user_id: 2 }, amount: 7 }, - ] + const items = [{ id: 1, name: 'Dinner' }, { id: 2, name: 'Taxi' }] + const sources = { + fronted: [{ item_id: 1, cents: 6000 }, { item_id: 3, cents: -1000 }], + moved: [ + { settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 1500 }, + { settlement_id: 2, from_user_id: 1, to_user_id: 3, cents: -500 }, + ], + outstanding: [{ from_user_id: 3, to_user_id: 1, cents: 501 }], + } - it('keeps what the participant fronted, a refund they received included', () => { - const { fronted } = finalBudgetSources(1, items, settlements, flows, i => paidByUser(i, 1)) - expect(fronted.map(f => [f.item.id, f.amount])).toEqual([[1, 60], [3, -10]]) + it("names the expenses and prints the server's cents as amounts, a refund's negative row included", () => { + const { fronted } = finalBudgetSources({ sources }, items) + // An expense the list does not know yet keeps its row; only the name is missing. + expect(fronted).toEqual([{ item_id: 1, name: 'Dinner', amount: 60 }, { item_id: 3, name: '?', amount: -10 }]) }) - it('keeps transfers and open flows on either side of the participant, and nobody else\'s', () => { - const { moved, outstanding } = finalBudgetSources(1, items, settlements, flows, i => paidByUser(i, 1)) - expect(moved.map(s => s.id)).toEqual([1, 2]) - expect(outstanding).toEqual([flows[0]]) + it('keeps the transfers and open flows signed as the server sent them', () => { + const { moved, outstanding } = finalBudgetSources({ sources }, items) + expect(moved).toEqual([ + { settlement_id: 1, from_user_id: 2, to_user_id: 1, amount: 15 }, + { settlement_id: 2, from_user_id: 1, to_user_id: 3, amount: -5 }, + ]) + expect(outstanding).toEqual([{ from_user_id: 3, to_user_id: 1, amount: 5.01 }]) }) - it('finds nothing for a participant with no activity', () => { - expect(finalBudgetSources(9, items, settlements, flows, i => paidByUser(i, 9))) + it('is empty for a participant with no activity', () => { + expect(finalBudgetSources({ sources: { fronted: [], moved: [], outstanding: [] } }, items)) .toEqual({ fronted: [], moved: [], outstanding: [] }) }) }) diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 85700ccd0..9aa6d401a 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -11,6 +11,7 @@ * Amounts are the raw input strings, parsed on use (same as customAmounts). */ +import type { BudgetParticipantFinal } from '@trek/shared' import { currencyDecimals } from '../../utils/formatters' // The split and receipt fields guard their own precision on every keystroke, so the @@ -61,33 +62,37 @@ export function paidByUser( } /** The figures a final budget is made of, for someone the server left out of the ledger. */ -export function finalBudgetFor( - finals: F[], - member: { id: number; username: string }, -): F | { user_id: number; username: string; avatar_url: null; expenses: number; reimbursed: number; pending: number; final: number } { +export function finalBudgetFor(finals: BudgetParticipantFinal[], member: { id: number; username: string }): BudgetParticipantFinal { // Absent means they neither fronted anything nor were split into an expense: // the trip has cost them nothing, which is worth a row of its own. - return finals.find(f => f.user_id === member.id) - || { user_id: member.id, username: member.username, avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0 } + return finals.find(f => f.user_id === member.id) || { + user_id: member.id, username: member.username, avatar_url: null, + expenses: 0, reimbursed: 0, pending: 0, final: 0, + sources: { fronted: [], moved: [], outstanding: [] }, + } } /** - * The rows behind one traveler's final budget: the expenses they fronted, the - * transfers already recorded on their side, and the flows still open on it. - * `paidOf` converts what they fronted into the display currency — the shells - * each own their exchange rates, so this stays free of them. + * The rows behind one traveler's final budget, ready to print. They arrive as + * ids and display cents: the server spreads each of the three figures over its + * rows with the same largest-remainder split the figure came from, so every list + * adds up to the line it sits under, in whatever currency was asked for and + * whether or not the live rates have loaded yet. Only the expense names are + * looked up here; who "you" is in a transfer is the shell's call. */ -export function finalBudgetSources( - userId: number, - items: I[], - settlements: S[], - flows: F[], - paidOf: (item: I) => number, -): { fronted: { item: I; amount: number }[]; moved: S[]; outstanding: F[] } { +export function finalBudgetSources( + row: Pick, + items: { id: number; name: string }[], +): { + fronted: { item_id: number; name: string; amount: number }[] + moved: { settlement_id: number; from_user_id: number; to_user_id: number; amount: number }[] + outstanding: { from_user_id: number; to_user_id: number; amount: number }[] +} { + const { fronted, moved, outstanding } = row.sources return { - fronted: items.map(item => ({ item, amount: paidOf(item) })).filter(x => x.amount !== 0), - moved: settlements.filter(s => s.from_user_id === userId || s.to_user_id === userId), - outstanding: flows.filter(f => f.from.user_id === userId || f.to.user_id === userId), + fronted: fronted.map(r => ({ item_id: r.item_id, name: items.find(i => i.id === r.item_id)?.name ?? '?', amount: r.cents / 100 })), + moved: moved.map(r => ({ settlement_id: r.settlement_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })), + outstanding: outstanding.map(r => ({ from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })), } } diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index a83360a70..577982115 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -835,33 +835,53 @@ describe('CostsPanel — overview', () => { }) it('FE-W5COSTS-004b: the final budget gives one amount per traveler and its arithmetic on click', async () => { - // Dinner 90 fronted by Alice, taxi 30 by Bob, both split evenly: the trip costs - // each of them 60. Bob has already sent 15 of the 30 he owes, 15 is still open. + // Dinner is 100 USD fronted by Alice, booked by the server at the rate frozen + // on entry as 92 €; taxi 30 € by Bob; both split evenly, so the trip costs each + // of them 61. Bob has sent 15 of the 31 he owed, 16 is still open. No live rate + // is loaded here, so a client converting the dinner itself would print 100 €. const user = userEvent.setup() - mount([dinner(), taxi()], { + mount([{ ...dinner(), currency: 'USD', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }, taxi()], { balances: [ - { user_id: 1, username: 'alice', avatar_url: null, balance: 15 }, - { user_id: 2, username: 'bob', avatar_url: null, balance: -15 }, + { user_id: 1, username: 'alice', avatar_url: null, balance: 16 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -16 }, ], - flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 15 }], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 16 }], settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 15, currency: 'EUR', created_at: '2025-06-17 10:00:00' }], finalBudgets: [ - { user_id: 1, username: 'alice', avatar_url: null, expenses: 90, reimbursed: 15, pending: 15, final: 60 }, - { user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -15, final: 60 }, + { + user_id: 1, username: 'alice', avatar_url: null, expenses: 92, reimbursed: 15, pending: 16, final: 61, + sources: { + fronted: [{ item_id: 101, cents: 9200 }], + moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: 1500 }], + outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 1600 }], + }, + }, + { + user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -16, final: 61, + sources: { + fronted: [{ item_id: 102, cents: 3000 }], + moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: -1500 }], + outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -1600 }], + }, + }, ], }) const card = (await screen.findByText('Final budget')).parentElement as HTMLElement - await waitFor(() => expect(within(card).getAllByText('60,00 €')).toHaveLength(2)) + await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2)) // The main view stays one figure per person until someone asks for more. expect(within(card).queryByText('Expenses paid')).toBeNull() const alice = within(card).getByRole('button', { name: /You/ }) await user.click(alice) expect(alice).toHaveAttribute('aria-expanded', 'true') - expect(within(card).getByText('+90,00 €')).toBeInTheDocument() - // Received and still pending both lower her cost. + // The dinner is the server's 92 €, once as the line and once as its only row; + // the raw 100 USD never shows up in the card. + expect(within(card).getAllByText('+92,00 €')).toHaveLength(2) + expect(within(card).queryByText(/100,00/)).toBeNull() + // Received and still pending both lower her cost, each line with its one row. expect(within(card).getAllByText('−15,00 €')).toHaveLength(2) + expect(within(card).getAllByText('−16,00 €')).toHaveLength(2) expect(within(card).getByText('Dinner')).toBeInTheDocument() expect(within(card).queryByText('Taxi')).toBeNull() expect(within(card).getAllByText(/^bob → /)).toHaveLength(2) @@ -869,8 +889,9 @@ describe('CostsPanel — overview', () => { // Opening Bob closes Alice: what he sent back and what he still owes raise his. await user.click(within(card).getByRole('button', { name: /bob/ })) expect(alice).toHaveAttribute('aria-expanded', 'false') - expect(within(card).getByText('+30,00 €')).toBeInTheDocument() + expect(within(card).getAllByText('+30,00 €')).toHaveLength(2) expect(within(card).getAllByText('+15,00 €')).toHaveLength(2) + expect(within(card).getAllByText('+16,00 €')).toHaveLength(2) expect(within(card).getByText('Taxi')).toBeInTheDocument() expect(within(card).queryByText('Dinner')).toBeNull() }) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 009859659..c27e5aba8 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -136,8 +136,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // ── derived expense maths (everything converted to the base currency) ──── const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e)) - const paidByOf = (e: BudgetItem, userId: number) => convert(paidByUser(e, userId), curOf(e)) - const myPaidOf = (e: BudgetItem) => paidByOf(e, me) + const myPaidOf = (e: BudgetItem) => convert(paidByUser(e, me), curOf(e)) // "Unfinished": a recorded total nobody has paid yet — counts toward the trip // total but stays out of settlements until who-paid is filled in. A negative // total (a refund, #2176) is just as unfinished until its recipient is named. @@ -979,13 +978,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } /** The three lines behind one traveler's final budget, and the rows they add up from. */ - function FinalBudgetBreakdown({ row }: { row: Pick }) { + function FinalBudgetBreakdown({ row }: { row: BudgetParticipantFinal }) { // Each line is signed by what it does to the final, so the column reads as the // subtraction it is: a reimbursement this traveler *sent* raises their cost and - // shows as a plus, which "received: −50" could never say. + // shows as a plus, which "received: −50" could never say. The rows under a line + // carry the same sign, so they visibly add up to it. Their amounts are the + // server's display cents, not a conversion of the expense list done here. const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v)) - const { fronted, moved, outstanding } = finalBudgetSources( - row.user_id, budgetItems, settlement?.settlements || [], settlement?.flows || [], e => paidByOf(e, row.user_id)) + const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems) const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const const detail = (label: string, value: string) => (
@@ -1008,10 +1008,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.finalExpenses')}
- {fronted.map(({ item, amount }) => ( -
- {item.name} - {fmt(amount)} + {fronted.map(r => ( +
+ {r.name} + {signed(r.amount)}
))}
@@ -1021,10 +1021,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.finalReimbursed')}
- {moved.map(s => ( -
- {transferLabel(s.from_user_id, s.to_user_id)} - {fmt(convert(s.amount, (s.currency || base).toUpperCase()))} + {moved.map(r => ( +
+ {transferLabel(r.from_user_id, r.to_user_id)} + {signed(-r.amount)}
))}
@@ -1034,10 +1034,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.finalPending')}
- {outstanding.map((f, i) => ( + {outstanding.map((r, i) => (
- {transferLabel(f.from.user_id, f.to.user_id)} - {fmt(f.amount)} + {transferLabel(r.from_user_id, r.to_user_id)} + {signed(-r.amount)}
))}
diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 30a94fd19..c11fbcd85 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -27,9 +27,10 @@ import { CountPill, TabScroller } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, currencyOf, - dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, paidByOf, tint, + dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, tint, type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse, } from './costsModel' +import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt, TripMember } from '../../../../types' type TFn = (key: string, params?: Record) => string @@ -330,7 +331,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { : } {open && ( - + )}
) @@ -783,26 +784,23 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE ) } -/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */ /** * The three lines behind one traveler's final budget, then the rows they come - * from. The figures are the server's — netted with the balances — and each line - * is signed by what it does to the final, so the column reads as a subtraction. + * from. Figures and rows are the server's, in the display currency and netted + * with the balances; each line is signed by what it does to the final, so the + * column reads as a subtraction and the rows add up to the line above them. */ -function FinalBudgetBreakdown({ row, items, settlement, ctx, base, locale, t, personName }: { - row: { user_id: number; expenses: number; reimbursed: number; pending: number } +function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: { + row: BudgetParticipantFinal items: BudgetItem[] - settlement: CostsSettlementResponse | null - ctx: CostsCtx base: string locale: string t: TFn personName: (id: number) => string }) { - const money = (v: number) => formatMoney(v, base, locale) - const signed = (v: number) => (v < 0 ? '−' : '+') + money(Math.abs(v)) - const { fronted, moved, outstanding } = finalBudgetSources( - row.user_id, items, settlement?.settlements || [], settlement?.flows || [], e => paidByOf(e, row.user_id, ctx)) + const signed = (v: number) => (v < 0 ? '−' : '+') + formatMoney(Math.abs(v), base, locale) + const { fronted, moved, outstanding } = finalBudgetSources(row, items) + const transfer = (fromId: number, toId: number) => `${personName(fromId)} → ${personName(toId)}` const line = (key: string, label: string, value: string) => (
{label} @@ -821,17 +819,14 @@ function FinalBudgetBreakdown({ row, items, settlement, ctx, base, locale, t, pe {line('expenses', t('costs.finalExpenses'), signed(row.expenses))} {line('reimbursed', t('costs.finalReimbursed'), signed(-row.reimbursed))} {line('pending', t('costs.finalPending'), signed(-row.pending))} - {section(t('costs.finalExpenses'), fronted.map(({ item, amount }) => line(`e${item.id}`, item.name, money(amount))))} - {section(t('costs.finalReimbursed'), moved.map(s => line( - `s${s.id}`, `${personName(s.from_user_id)} → ${personName(s.to_user_id)}`, money(ctx.convert(s.amount, (s.currency || base).toUpperCase())), - )))} - {section(t('costs.finalPending'), outstanding.map((f, i) => line( - `f${i}`, `${personName(f.from.user_id)} → ${personName(f.to.user_id)}`, money(f.amount), - )))} + {section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, r.name, signed(r.amount))))} + {section(t('costs.finalReimbursed'), moved.map(r => line(`s${r.settlement_id}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))} + {section(t('costs.finalPending'), outstanding.map((r, i) => line(`f${i}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
) } +/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: { name: string avatarUrl?: string | null diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts index c8594c5e8..6ccbbfaf8 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -31,14 +31,9 @@ export function baseTotal(e: BudgetItem, ctx: CostsCtx): number { return ctx.convert(e.total_price || 0, currencyOf(e, ctx)) } -/** How much a given participant fronted for this expense, in the base currency. */ -export function paidByOf(e: BudgetItem, userId: number, ctx: CostsCtx): number { - return ctx.convert(paidByUser(e, userId), currencyOf(e, ctx)) -} - /** How much `ctx.me` personally fronted for this expense, in the base currency. */ export function myPaidOf(e: BudgetItem, ctx: CostsCtx): number { - return paidByOf(e, ctx.me, ctx) + return ctx.convert(paidByUser(e, ctx.me), currencyOf(e, ctx)) } /** A given member's share of this expense (explicit custom amount, else equal split), base currency. */ diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 3e1f3176f..7e6b65c90 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -14,7 +14,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store' import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' -// FE-MOB-COSTT-001 to FE-MOB-COSTT-037 +// FE-MOB-COSTT-001 to FE-MOB-COSTT-043 // The add/edit expense sheet is the shared desktop-sized form; the panel only // owns when it opens and what happens on save, so it is stubbed here. @@ -580,41 +580,70 @@ describe('MCostsTab', () => { expect(within(row).getByText('$15.00')).toBeInTheDocument() }) - it('FE-MOB-COSTT-041: shows what the trip costs each traveler and opens the arithmetic on tap', async () => { + it('FE-MOB-COSTT-043: shows what the trip costs each traveler and opens the arithmetic on tap', async () => { + // The rows come from the server in display cents. Bob's museum is a GBP expense + // the server booked at its frozen rate as $58.00; the live rate this test seeds + // would make it $60.00, and that figure must never reach the card. serveSettlement({ ...SETTLEMENT, - settlements: [{ id: 5, from_user_id: 3, to_user_id: 1, amount: 15, currency: 'USD', created_at: '2026-05-02 10:00:00' }], + settlements: [{ id: 5, from_user_id: 3, to_user_id: 1, amount: 10, currency: 'USD', created_at: '2026-05-02 10:00:00' }], finalBudgets: [ - { user_id: 1, username: 'Me', avatar_url: null, expenses: 100, reimbursed: 15, pending: 12.5, final: 72.5 }, + { + user_id: 1, username: 'Me', avatar_url: null, expenses: 100, reimbursed: 10, pending: -25, final: 115, + sources: { + fronted: [{ item_id: 11, cents: 8000 }, { item_id: 14, cents: 2000 }], + moved: [{ settlement_id: 5, from_user_id: 3, to_user_id: 1, cents: 1000 }], + outstanding: [{ from_user_id: 1, to_user_id: 2, cents: -4000 }, { from_user_id: 3, to_user_id: 1, cents: 1500 }], + }, + }, + { + user_id: 3, username: 'Bob', avatar_url: null, expenses: 58, reimbursed: -10, pending: -10, final: 78, + sources: { + fronted: [{ item_id: 12, cents: 5800 }], + moved: [{ settlement_id: 5, from_user_id: 3, to_user_id: 1, cents: -1000 }], + outstanding: [{ from_user_id: 3, to_user_id: 1, cents: -1500 }, { from_user_id: 99, to_user_id: 3, cents: 500 }], + }, + }, ], }) await renderTab() const card = up(screen.getByText('costs.finalBudget'), 1) - // One amount per traveler; the two the ledger left out cost nothing. - expect(within(card).getByText('$72.50')).toBeInTheDocument() - expect(within(card).getAllByText('$0.00')).toHaveLength(2) + // One amount per traveler; Ada, whom the ledger left out, costs nothing. + expect(within(card).getByText('$115.00')).toBeInTheDocument() + expect(within(card).getByText('$78.00')).toBeInTheDocument() + expect(within(card).getByText('$0.00')).toBeInTheDocument() expect(within(card).queryByText('costs.finalPending')).not.toBeInTheDocument() - const mine = within(card).getByRole('button', { name: /\$72\.50/ }) + const mine = within(card).getByRole('button', { name: /\$115\.00/ }) fireEvent.click(mine) expect(mine).toHaveAttribute('aria-expanded', 'true') - // Each line signed by what it does to the final. + // Each line signed by what it does to the final, and its rows signed the same + // way, so they add up to it on sight. expect(within(card).getByText('+$100.00')).toBeInTheDocument() + expect(within(card).getByText('+$80.00')).toBeInTheDocument() + expect(within(card).getByText('+$20.00')).toBeInTheDocument() + expect(within(card).getAllByText('−$10.00')).toHaveLength(2) + expect(within(card).getByText('+$25.00')).toBeInTheDocument() + expect(within(card).getByText('+$40.00')).toBeInTheDocument() expect(within(card).getByText('−$15.00')).toBeInTheDocument() - expect(within(card).getByText('−$12.50')).toBeInTheDocument() - // The rows behind it: only the expenses I fronted, the transfer I received, - // and the flows still open on my side. expect(within(card).getByText('Ramen')).toBeInTheDocument() expect(within(card).getByText('Taxi')).toBeInTheDocument() expect(within(card).queryByText('Museum')).not.toBeInTheDocument() expect(within(card).getAllByText('Bob → costs.you')).toHaveLength(2) expect(within(card).getByText('costs.you → Ada')).toBeInTheDocument() - expect(within(card).queryByText('? → Bob')).not.toBeInTheDocument() - fireEvent.click(mine) + // Opening Bob closes me. His museum row is the server's figure, not a live conversion. + const bob = within(card).getByRole('button', { name: /\$78\.00/ }) + fireEvent.click(bob) expect(mine).toHaveAttribute('aria-expanded', 'false') - expect(within(card).queryByText('+$100.00')).not.toBeInTheDocument() + expect(within(card).getAllByText('+$58.00')).toHaveLength(2) + expect(within(card).queryByText(/60\.00/)).not.toBeInTheDocument() + expect(within(card).getByText('Museum')).toBeInTheDocument() + + fireEvent.click(bob) + expect(bob).toHaveAttribute('aria-expanded', 'false') + expect(within(card).queryByText('+$58.00')).not.toBeInTheDocument() }) it('FE-MOB-COSTT-040: the expense filters apply to payments the same way they do on desktop', async () => { diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index c286497d3..5664e0c92 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -390,7 +390,7 @@ export class BudgetMcp { @Tool({ name: 'get_settlement_summary', - description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`). Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.", + description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.", inputSchema: { tripId: z.number().int().positive(), base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'), diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index e3fcc951d..186810b91 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -69,11 +69,18 @@ function sumMoney(amounts: number[]): number { return amounts.reduce((a, v) => a + Math.round(v * 100), 0) / 100; } -function allocateDisplayCents(cents: number[], factor: number): number[] { +/** + * Convert a set of trip cents to display cents so that they still add up: floor + * each one, then hand the cents lost to flooring to the largest fractions. `total` + * is what the set has to sum to. By default that is the rounded conversion of its + * own sum; the rows behind a figure pass the figure's already allocated cents + * instead, so a list nested under a line lands exactly on that line. + */ +function allocateDisplayCents(cents: number[], factor: number, total = Math.round(cents.reduce((a, c) => a + c, 0) * factor)): number[] { if (factor === 1) return [...cents]; const exact = cents.map(c => c * factor); const out = exact.map(v => Math.floor(v)); - const drift = Math.round(cents.reduce((a, c) => a + c, 0) * factor) - out.reduce((a, v) => a + v, 0); + const drift = total - out.reduce((a, v) => a + v, 0); const byFraction = exact .map((v, i) => ({ i, frac: v - Math.floor(v) })) .sort((a, b) => b.frac - a.frac || a.i - b.i); @@ -857,7 +864,10 @@ export class BudgetService { * cost me", which no other figure on the Costs screen gives: a participant's * gross outlay minus the reimbursements already recorded minus the ones still * to come. It is derived from the same integer cents rather than recomputed, so - * a breakdown can never contradict the balance printed next to it. + * a breakdown can never contradict the balance printed next to it. The rows + * behind each figure (`sources`) travel with it, in the same display cents: an + * expense list converted again on the client with today's rate would not add up + * to a figure that was booked at the rate frozen on entry. */ calculateSettlement( tripId: string | number, @@ -949,6 +959,10 @@ export class BudgetService { // are derived from. const frontedCents: Record = {}; const reimbursedCents: Record = {}; + // ...and the rows they are made of, so the breakdown lists them in these same + // cents rather than converting the expense list a second time on the client. + const frontedRows: Record = {}; + const movedRows: Record = {}; for (const item of items) { const members = allMembers.filter(m => m.budget_item_id === item.id); @@ -981,6 +995,7 @@ export class BudgetService { const paid = toTripCents(p.amount, item.currency, item.exchange_rate); ensure(p.user_id, p).cents += paid; frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + paid; + if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: paid }); creditCents += paid; } // …and each split participant owes their share — a custom per-member amount @@ -1025,6 +1040,9 @@ export class BudgetService { // subtraction equal to the balance it is taken from. reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + inTrip; reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - inTrip; + const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id }; + (movedRows[s.to_user_id] ??= []).push({ ...moved, cents: inTrip }); + (movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -inTrip }); } // Into the display currency as one set, then simplify — balances and flows are @@ -1075,13 +1093,35 @@ export class BudgetService { })), flows, settlements, - finalBudgets: ledger.map((b, i) => ({ - user_id: b.user_id, username: b.username, avatar_url: b.avatar_url, - expenses: frontedDisplayCents[i] / 100, - reimbursed: reimbursedDisplayCents[i] / 100, - pending: displayCents[i] / 100, - final: (frontedDisplayCents[i] - reimbursedDisplayCents[i] - displayCents[i]) / 100, - })) satisfies BudgetParticipantFinal[], + finalBudgets: ledger.map((b, i) => { + // The rows behind each figure, converted against the figure itself: the + // same largest-remainder split, with the cents already allocated to the + // figure as the target, so each list adds up to the line it sits under. + const fronted = frontedRows[b.user_id] || []; + const moved = movedRows[b.user_id] || []; + const frontedDisplay = allocateDisplayCents(fronted.map(r => r.cents), displayFactor, frontedDisplayCents[i]); + const movedDisplay = allocateDisplayCents(moved.map(r => r.cents), displayFactor, reimbursedDisplayCents[i]); + return { + user_id: b.user_id, username: b.username, avatar_url: b.avatar_url, + expenses: frontedDisplayCents[i] / 100, + reimbursed: reimbursedDisplayCents[i] / 100, + pending: displayCents[i] / 100, + final: (frontedDisplayCents[i] - reimbursedDisplayCents[i] - displayCents[i]) / 100, + sources: { + fronted: fronted.map((r, k) => ({ item_id: r.item_id, cents: frontedDisplay[k] })), + moved: moved.map((r, k) => ({ ...r, cents: movedDisplay[k] })), + // The flows are display cents already, and the greedy pass drains every + // balance completely, so the flows on a person's side sum to their balance. + outstanding: flows + .filter(f => f.from.user_id === b.user_id || f.to.user_id === b.user_id) + .map(f => ({ + from_user_id: f.from.user_id, + to_user_id: f.to.user_id, + cents: Math.round(f.amount * 100) * (f.to.user_id === b.user_id ? 1 : -1), + })), + }, + }; + }) satisfies BudgetParticipantFinal[], }; } diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index fbf84049f..9fe0398e2 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -668,6 +668,82 @@ describe('calculateSettlement — finalBudgets', () => { expect(result.finalBudgets.find(f => f.user_id === 2)!).toMatchObject({ expenses: 0, reimbursed: -40, pending: 40, final: 0 }); checkIdentity(result.finalBudgets); }); + + it('lists the rows each figure is made of, signed the way the figure is', () => { + // The first case read row by row, with 20 of Bob's 50 already sent: Alice + // fronted the one expense, received the 20, and the open flow is the 30 coming + // to her; on Bob's side the same transfer and flow are going out. + setupDb( + [makeItem(1, 100)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], + [makeSettlementRow(1, 2, 1, 20)], + ); + const result = budget.calculateSettlement(1); + + expect(result.finalBudgets.find(f => f.user_id === 1)!.sources).toEqual({ + fronted: [{ item_id: 1, cents: 10000 }], + moved: [{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 2000 }], + outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 3000 }], + }); + expect(result.finalBudgets.find(f => f.user_id === 2)!.sources).toEqual({ + fronted: [], + moved: [{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: -2000 }], + outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -3000 }], + }); + }); + + it('keeps an expense with no split members out of the rows, as it is out of the ledger', () => { + // Item 1 has a payer but nobody to split it with: a planning-only entry that + // charges nobody, so it cannot be listed as something Alice fronted either. + setupDb( + [makeItem(1, 100), makeItem(2, 40)], + [makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')], + [makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 40, 'alice')], + ); + const result = budget.calculateSettlement(1); + + const alice = result.finalBudgets.find(f => f.user_id === 1)!; + expect(alice.expenses).toBe(40); + expect(alice.sources.fronted).toEqual([{ item_id: 2, cents: 4000 }]); + }); + + it('spreads a foreign-currency figure over its rows so they still add up in the display currency', () => { + // Two USD expenses booked at their own frozen rates and one in the trip's euros, + // viewed in pounds: every row goes through the conversion its figure went + // through, and the cents lost to rounding land on rows instead of between them. + const sum = (rows: { cents: number }[]) => rows.reduce((a, r) => a + r.cents, 0); + setupDb( + [ + { ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.08 }, + { ...makeItem(2, 33.33), currency: 'USD', exchange_rate: 1.1 }, + makeItem(3, 50), + ], + [ + makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), + makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol'), + makeMember(3, 1, 'alice'), makeMember(3, 2, 'bob'), makeMember(3, 3, 'carol'), + ], + [makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 33.33, 'alice'), makePayer(3, 2, 50, 'bob')], + [makeSettlementRow(1, 3, 1, 20, 'GBP', 0.8547), makeSettlementRow(2, 3, 2, 7.77, 'GBP', 0.8547)], + ); + for (const eurPerGbp of [1.17, 1.1523, 1.2]) { + const result = budget.calculateSettlement(1, { base: 'GBP', tripCurrency: 'EUR', rates: { GBP: 1, EUR: eurPerGbp } }); + + checkIdentity(result.finalBudgets); + for (const f of result.finalBudgets) { + expect(sum(f.sources.fronted)).toBe(Math.round(f.expenses * 100)); + expect(sum(f.sources.moved)).toBe(Math.round(f.reimbursed * 100)); + expect(sum(f.sources.outstanding)).toBe(Math.round(f.pending * 100)); + } + // Alice's two rows each stay within a cent of their own conversion: the + // remainder is handed out, not rounded away one row at a time. + const alice = result.finalBudgets.find(f => f.user_id === 1)!; + expect(alice.sources.fronted.map(r => r.item_id)).toEqual([1, 2]); + expect(Math.abs(alice.sources.fronted[0].cents - Math.round(100 / 1.08 * 100) / eurPerGbp)).toBeLessThan(1); + expect(Math.abs(alice.sources.fronted[1].cents - Math.round(33.33 / 1.1 * 100) / eurPerGbp)).toBeLessThan(1); + } + }); }); describe('splitEqualShares — client parity (#2176)', () => { diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index 36169f8e6..1e7978014 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -269,6 +269,12 @@ export type BudgetUpdateSettlementRequest = z.infer; diff --git a/wiki/Budget-Tracking.md b/wiki/Budget-Tracking.md index 520d7bd09..0d15e8d1b 100644 --- a/wiki/Budget-Tracking.md +++ b/wiki/Budget-Tracking.md @@ -87,9 +87,9 @@ Costs works out the minimum number of transfers needed to settle all debts (usin - **Settle up** — the transfer flows: who pays whom and how much. The number of open flows sits in the card header, and each flow has a **Settle** button that records it as done. - **Balances** — net balances: each member's overall surplus or deficit. -- **Final budget** — what the trip costs each member once every reimbursement is accounted for: **expenses paid − reimbursements received − pending reimbursements**. Clicking a name opens that breakdown, with the expenses the member paid, the payments already recorded and the transfers still open on their side. +- **Final budget** — what the trip costs each member once every reimbursement is accounted for: **expenses paid − net reimbursements − pending reimbursements**. Clicking a name opens that breakdown, with the expenses the member paid, the payments already recorded and the transfers still open on their side. The server works those rows out in the currency you are viewing in, at the exchange rate each expense was booked at, so every list adds up to the line above it even before live rates have loaded. -The final budget equals each member's share of the paid expenses, so recording a payment moves an amount from *pending* to *received* without changing it. An expense nobody has paid yet stays out of it, as it stays out of the balances. +The final budget comes to each member's share of the paid expenses (exact in the trip's own currency; in another display currency, rounding can leave a single figure a cent off while the column still adds up), so recording a payment moves an amount from *pending* to *net reimbursements* without changing it. An expense nobody has paid yet stays out of it, as it stays out of the balances. **Settle up** in the panel header records every open flow at once. **Add payment** on the card records a single transfer by hand, for a repayment that did not follow a suggested flow. Recorded payments then appear in the expense ledger as their own rows, with edit and undo beside them. diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 9baffb31e..de2fa1818 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -146,7 +146,7 @@ Requires `budget:read` or `budget:write` scope. Budget addon must be enabled. | `delete_budget_item` | Remove a budget item. | | `set_budget_item_members` | Set which members are splitting a budget item (replaces current list). | | `toggle_budget_member_paid` | Mark or unmark a member as having paid their share. | -| `get_settlement_summary` | Each member's net balance, the suggested payments to settle shared expenses, and each member's final budget (`finalBudgets`: expenses paid, reimbursements received, reimbursements pending, final cost), in the trip's base currency. Call this before recording a settlement. | +| `get_settlement_summary` | Each member's net balance, the suggested payments to settle shared expenses, and each member's final budget (`finalBudgets`: expenses paid, net reimbursements, pending reimbursements, final cost, each figure with the rows it is made of under `sources`), in the trip's base currency. Call this before recording a settlement. | | `list_settlements` | List the recorded settle-up payments for a trip — who paid whom, how much, and when. | | `create_settlement` | Record a settle-up payment: one member paid another the given amount, with the payment's currency and the day it happened. | | `update_settlement` | Update a recorded settle-up payment (payer, recipient, amount, currency and the day it happened). |