diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index b9c14a703..093ee2ae6 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' describe('splitCents', () => { it('splits evenly when it divides cleanly', () => { @@ -246,3 +246,63 @@ describe('calculateTicketShares', () => { expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 }) }) }) + +describe('paidByUser', () => { + it('adds up what one participant fronted and ignores the other payers', () => { + const item = { payers: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }, { user_id: 1, amount: 5 }] } + expect(paidByUser(item, 1)).toBe(35) + expect(paidByUser(item, 2)).toBe(70) + expect(paidByUser(item, 3)).toBe(0) + }) + + it('is zero for an expense with no payer list', () => { + expect(paidByUser({ payers: null }, 1)).toBe(0) + expect(paidByUser({}, 1)).toBe(0) + }) +}) + +describe('finalBudgetFor', () => { + const finals = [{ user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50 }] + + 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 }) + }) +}) + +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 }, + ] + + 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('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('finds nothing for a participant with no activity', () => { + expect(finalBudgetSources(9, items, settlements, flows, i => paidByUser(i, 9))) + .toEqual({ fronted: [], moved: [], outstanding: [] }) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 8ea99177f..85700ccd0 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -45,6 +45,52 @@ export function settlementDate(s: { settled_at?: string | null; created_at?: str return (s.settled_at || s.created_at || '').slice(0, 10) } +/** + * What one participant fronted for an expense, in the expense's own currency. + * + * Several payers can share one bill, and the same person can appear only once, + * but the reduce covers a row that somehow carries them twice rather than + * picking one of the two. The caller converts the result — this file never + * touches exchange rates. + */ +export function paidByUser( + item: { payers?: { user_id: number; amount: number }[] | null }, + userId: number, +): number { + return (item.payers || []).filter(p => p.user_id === userId).reduce((a, p) => a + p.amount, 0) +} + +/** 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 } { + // 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 } +} + +/** + * 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. + */ +export function finalBudgetSources( + userId: number, + items: I[], + settlements: S[], + flows: F[], + paidOf: (item: I) => number, +): { fronted: { item: I; amount: number }[]; moved: S[]; outstanding: F[] } { + 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), + } +} + /** Sum the amounts of the selected payers. */ export function payerSum(amounts: Record, ids: Set): number { return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0) diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 71613bcc7..a83360a70 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore' import { clearExchangeRateCache } from '../../hooks/useExchangeRates' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories' +import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetItem } from '../../types' import CostsPanel, { ExpenseModal } from './CostsPanel' import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers' @@ -20,6 +21,9 @@ const tripMembers = [ { id: 2, username: 'bob', avatar_url: null }, ] +/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */ +const bobInForm = () => screen.getAllByRole('button', { name: /bob/i }).filter(b => !b.hasAttribute('aria-expanded')) + beforeEach(() => { resetAllStores() seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) @@ -610,7 +614,7 @@ describe('CostsPanel — settlements in the ledger', () => { await user.type(itemNames[1], 'chocolate cake') await user.type(itemPrices[2], '50') - const bobButtons = screen.getAllByRole('button', { name: /bob/i }) + const bobButtons = bobInForm() await user.click(bobButtons[1]) await user.type(itemNames[2], 'Milk') @@ -749,7 +753,7 @@ const expense = (over: Partial> & { members?: Member function mount( items: BudgetItem[], - settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[] } = {}, + settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[]; finalBudgets?: BudgetParticipantFinal[] } = {}, entries?: string[], ) { server.use( @@ -826,8 +830,49 @@ describe('CostsPanel — overview', () => { mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] }) // Both travellers appear; neither has a signed amount. - await screen.findByText('Balances') - expect(screen.getAllByText('0,00 €')).toHaveLength(2) + const balances = (await screen.findByText('Balances')).parentElement as HTMLElement + expect(within(balances).getAllByText('0,00 €')).toHaveLength(2) + }) + + 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. + const user = userEvent.setup() + mount([dinner(), taxi()], { + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 15 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -15 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 15 }], + 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 }, + ], + }) + + const card = (await screen.findByText('Final budget')).parentElement as HTMLElement + await waitFor(() => expect(within(card).getAllByText('60,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. + expect(within(card).getAllByText('−15,00 €')).toHaveLength(2) + expect(within(card).getByText('Dinner')).toBeInTheDocument() + expect(within(card).queryByText('Taxi')).toBeNull() + expect(within(card).getAllByText(/^bob → /)).toHaveLength(2) + + // 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('+15,00 €')).toHaveLength(2) + expect(within(card).getByText('Taxi')).toBeInTheDocument() + expect(within(card).queryByText('Dinner')).toBeNull() }) it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => { @@ -980,8 +1025,8 @@ describe('CostsPanel — settle up', () => { render() await screen.findByText('Balances') - // Settle up and Balances both hang off the one failed request. - await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2)) + // Settle up, Balances and Final budget all hang off the one failed request. + await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(3)) expect(screen.queryByText("Everyone's square")).toBeNull() }) @@ -1360,7 +1405,7 @@ describe('CostsPanel — expense modal', () => { const rows = screen.getAllByPlaceholderText('Item name') await user.click(rows[1].parentElement!.parentElement!.querySelectorAll('button')[0]) expect(screen.queryByDisplayValue('Cake')).not.toBeInTheDocument() - await user.click(screen.getAllByRole('button', { name: /bob/i })[0]) + await user.click(bobInForm()[0]) await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(put).toBeTruthy()) @@ -1401,7 +1446,8 @@ describe('CostsPanel — expense modal', () => { expect(screen.getByDisplayValue('30,00')).toBeInTheDocument() // Excluding Bob drops his amount; the split no longer matches the total. - await user.click(screen.getByRole('button', { name: /bob/i })) + expect(bobInForm()).toHaveLength(1) + await user.click(bobInForm()[0]) expect(screen.getByText(/Sum of splits/)).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index bf11bc6aa..009859659 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -18,9 +18,10 @@ import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { localToday } from '../Planner/today' import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' -import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import { ReceiptPreviewModal } from './ReceiptPreviewModal' +import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' @@ -52,6 +53,9 @@ interface SettlementData { balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[] flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[] settlements: Settlement[] + // What the trip ends up costing each participant. Computed server-side off the + // same ledger as the balances, so the breakdown can't contradict them. + finalBudgets: BudgetParticipantFinal[] } // One row in the unified Costs ledger — either an expense or a settle-up payment, @@ -94,6 +98,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // One note open at a time: two expanded rows next to each other read as a mess, // and the point of the collapse is that the list stays scannable. const [expandedNoteId, setExpandedNoteId] = useState(null) + // One open final-budget breakdown at a time, for the same reason a single note + // is expanded at a time: the card is a sidebar, not a report. + const [expandedFinalId, setExpandedFinalId] = useState(null) const [editingSettlement, setEditingSettlement] = useState(null) const [addingPayment, setAddingPayment] = useState(false) @@ -129,7 +136,8 @@ 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 myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0) + const paidByOf = (e: BudgetItem, userId: number) => convert(paidByUser(e, userId), curOf(e)) + const myPaidOf = (e: BudgetItem) => paidByOf(e, me) // "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. @@ -478,6 +486,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {BalancesList({ balances: settlement?.balances || [] })} + {/* final budget */} +
+
{t('costs.finalBudget')}
+ {FinalBudgetList()} +
+ {/* by category */}
{t('costs.byCategory')}
@@ -698,6 +712,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {BalancesList({ balances: settlement?.balances || [] })}
+ {/* Final budget */} +
+
{t('costs.finalBudget')}
+ {FinalBudgetList()} +
+ {/* By category */}
{t('costs.byCategory')}
@@ -922,6 +942,111 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ) } + /** + * What the trip actually costs each traveler — one amount per person, with the + * arithmetic behind it a click away. + * + * The four figures come from the settlement response, not from a second pass + * over the expenses here: the server nets them in the same integer cents as + * the balances, so `expenses − reimbursed − pending` always lands on the total + * printed beside the name. The lists under the breakdown are the rows those + * figures came from, which is what makes an unexpected total explainable. + */ + function FinalBudgetList() { + if (settlementError) return loadFailed() + const finals = settlement?.finalBudgets || [] + const rows = people.map(p => finalBudgetFor(finals, p)) + return ( +
+ {rows.map(r => { + const open = expandedFinalId === r.user_id + return ( +
+ + {open && FinalBudgetBreakdown({ row: r })} +
+ ) + })} +
+ ) + } + + /** The three lines behind one traveler's final budget, and the rows they add up from. */ + function FinalBudgetBreakdown({ row }: { row: Pick }) { + // 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. + 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 lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const + const detail = (label: string, value: string) => ( +
+ {label} + {value} +
+ ) + const transferLabel = (fromId: number, toId: number) => `${personName(fromId)} → ${toId === me ? t('costs.youLower') : personName(toId)}` + // Capped and scrollable: a long trip's expense list would otherwise push the + // rest of the sidebar off the screen every time a name is tapped. + const listCls = { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 6, maxHeight: 180, overflowY: 'auto' } as const + return ( +
+
+ {detail(t('costs.finalExpenses'), signed(row.expenses))} + {detail(t('costs.finalReimbursed'), signed(-row.reimbursed))} + {detail(t('costs.finalPending'), signed(-row.pending))} +
+ {fronted.length > 0 && ( +
+
{t('costs.finalExpenses')}
+
+ {fronted.map(({ item, amount }) => ( +
+ {item.name} + {fmt(amount)} +
+ ))} +
+
+ )} + {moved.length > 0 && ( +
+
{t('costs.finalReimbursed')}
+
+ {moved.map(s => ( +
+ {transferLabel(s.from_user_id, s.to_user_id)} + {fmt(convert(s.amount, (s.currency || base).toUpperCase()))} +
+ ))} +
+
+ )} + {outstanding.length > 0 && ( +
+
{t('costs.finalPending')}
+
+ {outstanding.map((f, i) => ( +
+ {transferLabel(f.from.user_id, f.to.user_id)} + {fmt(f.amount)} +
+ ))} +
+
+ )} +
+ ) + } + function CategoryBreakdown() { // Categories net refunds against spend (#2176): a negative entry lowers its // category's sum, and a category that nets negative keeps its own row — diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 4c3db1035..30a94fd19 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp, Layers, Pencil, Plus, RotateCcw, StickyNote, Trash2, Receipt, @@ -13,7 +13,7 @@ import { downloadBlob, openFile } from '../../../../utils/fileDownload' import { budgetApi } from '../../../../api/client' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' -import { readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' +import { finalBudgetFor, finalBudgetSources, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import CustomSelect from '../../../../components/shared/CustomSelect' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' @@ -27,7 +27,7 @@ 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, tint, + dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, paidByOf, tint, type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse, } from './costsModel' import type { BudgetItem, BudgetItemReceipt, TripMember } from '../../../../types' @@ -79,6 +79,8 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const [catOpen, setCatOpen] = useState(false) const [dayOpen, setDayOpen] = useState(false) const [settleOpen, setSettleOpen] = useState(true) + // One traveler's final-budget breakdown open at a time; the list stays scannable. + const [expandedFinalId, setExpandedFinalId] = useState(null) const [addPaymentOpen, setAddPaymentOpen] = useState(false) const [editingSettlement, setEditingSettlement] = useState(null) const [expenseModalOpen, setExpenseModalOpen] = useState(false) @@ -306,6 +308,35 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { )}
+ {/* Final budget — what the trip costs each traveler; the arithmetic opens on tap */} +
+
{t('costs.finalBudget')}
+ {tripMembers.map(p => { + const row = finalBudgetFor(settlement?.finalBudgets || [], p) + const open = expandedFinalId === p.id + return ( +
+ + {open && ( + + )} +
+ ) + })} +
+ {/* By category (spec §3.5) */}
{t('costs.byCategory')}
@@ -753,6 +784,54 @@ 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. + */ +function FinalBudgetBreakdown({ row, items, settlement, ctx, base, locale, t, personName }: { + row: { user_id: number; expenses: number; reimbursed: number; pending: number } + 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 line = (key: string, label: string, value: string) => ( +
+ {label} + {value} +
+ ) + // Capped and scrollable so a long trip's list can't push the page away. + const section = (title: string, rows: ReactNode[]) => rows.length > 0 && ( +
+
{title}
+
{rows}
+
+ ) + return ( +
+ {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), + )))} +
+ ) +} + 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 11dc955a9..c8594c5e8 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -1,5 +1,5 @@ -import type { CostCategory } from '@trek/shared' -import { readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers' +import type { BudgetParticipantFinal, CostCategory } from '@trek/shared' +import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories' import { currencyDecimals } from '../../../../utils/formatters' import type { BudgetItem } from '../../../../types' @@ -31,11 +31,14 @@ 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 (e.payers || []) - .filter(p => p.user_id === ctx.me) - .reduce((a, p) => a + ctx.convert(p.amount, currencyOf(e, ctx)), 0) + return paidByOf(e, ctx.me, ctx) } /** A given member's share of this expense (explicit custom amount, else equal split), base currency. */ @@ -96,6 +99,8 @@ export interface CostsSettlementResponse { balances: CostsBalance[] flows: CostsSettlementFlow[] settlements: CostsSettlement[] + /** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */ + finalBudgets: BudgetParticipantFinal[] } // ── hero / tile totals (spec §3.1-§3.3) ──────────────────────────────────── diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 5a602c501..3e1f3176f 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -580,6 +580,43 @@ 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 () => { + 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' }], + finalBudgets: [ + { user_id: 1, username: 'Me', avatar_url: null, expenses: 100, reimbursed: 15, pending: 12.5, final: 72.5 }, + ], + }) + 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) + expect(within(card).queryByText('costs.finalPending')).not.toBeInTheDocument() + + const mine = within(card).getByRole('button', { name: /\$72\.50/ }) + fireEvent.click(mine) + expect(mine).toHaveAttribute('aria-expanded', 'true') + // Each line signed by what it does to the final. + expect(within(card).getByText('+$100.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) + expect(mine).toHaveAttribute('aria-expanded', 'false') + expect(within(card).queryByText('+$100.00')).not.toBeInTheDocument() + }) + it('FE-MOB-COSTT-040: the expense filters apply to payments the same way they do on desktop', async () => { const mine = { id: 503, from_user_id: 1, to_user_id: 2, amount: 8, currency: 'USD', created_at: '2026-05-02T09:00:00Z' } const others = { id: 504, from_user_id: 2, to_user_id: 3, amount: 9, currency: 'USD', created_at: '2026-05-02T09:00:00Z' } diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 54cc3950a..c286497d3 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 and the suggested payments to settle shared expenses. 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`). 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 4bdeb3858..e3fcc951d 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService, type TripAccess } from '../database/database.service'; -import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; +import type { BudgetParticipantFinal, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../common/avatarUrl'; @@ -847,6 +847,18 @@ export class BudgetService { return shares; } + /** + * Who owes whom (`balances` + the simplified `flows`), the recorded transfers + * (`settlements`), and what the trip ends up costing each participant + * (`finalBudgets`). + * + * The final budget is the same ledger read from the other end. The balances + * answer "who still has to pay whom"; `finalBudgets` answers "what did the trip + * 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. + */ calculateSettlement( tripId: string | number, opts: { base?: string; rates?: Record | null; tripCurrency?: string } = {}, @@ -930,6 +942,13 @@ export class BudgetService { if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0 }; return balances[id]; }; + // The two halves of the balance, kept apart so the per-person final budget can + // show its own arithmetic: what each person fronted, and what the recorded + // transfers have already moved back. Same trip cents as `balances`, filled by + // the same two loops, so the three figures cannot drift from the balance they + // are derived from. + const frontedCents: Record = {}; + const reimbursedCents: Record = {}; for (const item of items) { const members = allMembers.filter(m => m.budget_item_id === item.id); @@ -961,6 +980,7 @@ export class BudgetService { for (const p of payers) { 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; creditCents += paid; } // …and each split participant owes their share — a custom per-member amount @@ -1000,6 +1020,11 @@ export class BudgetService { const inTrip = Math.round(settleToTrip(s.amount, s.currency, s.exchange_rate) * 100); ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip; ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip; + // Net of the transfers in both directions: sending one back is a reimbursement + // received in reverse, and netting them is what keeps the final budget's + // 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; } // Into the display currency as one set, then simplify — balances and flows are @@ -1007,6 +1032,14 @@ export class BudgetService { // what "Settle up" offers to move, down to the last cent (#1382). const ledger = Object.values(balances); const displayCents = allocateDisplayCents(ledger.map(b => b.cents), displayFactor); + // Each component of the final budget is re-denominated as its own set, for the + // same reason the balances are: rounding one person at a time lets the column + // drift away from the figure it converted from. The final itself is then + // subtracted in display cents rather than converted separately, so the three + // lines the breakdown shows always add up to the total beside them, whatever + // currency the viewer picked. + const frontedDisplayCents = allocateDisplayCents(ledger.map(b => frontedCents[b.user_id] || 0), displayFactor); + const reimbursedDisplayCents = allocateDisplayCents(ledger.map(b => reimbursedCents[b.user_id] || 0), displayFactor); // Calculate optimized payment flows (greedy algorithm) const people = ledger @@ -1042,6 +1075,13 @@ 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[], }; } diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index 9b53a5d52..fbf84049f 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -520,6 +520,156 @@ const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: numbe { totalCents: -1, users: [1, 2, 3], itemId: 0, expected: { 1: 0, 2: 0, 3: -1 } }, ]; +// ── Final budget per participant ────────────────────────────────────────── + +describe('calculateSettlement — finalBudgets', () => { + /** + * The whole point of the figure: gross outlay minus what came back minus what + * still has to. It is the same ledger the balances come from, read from the + * other end, so every case here also pins that the subtraction the UI prints + * lands on the number beside it. + */ + const checkIdentity = (rows: { expenses: number; reimbursed: number; pending: number; final: number }[]) => { + for (const r of rows) { + expect(Math.round(r.final * 100)) + .toBe(Math.round(r.expenses * 100) - Math.round(r.reimbursed * 100) - Math.round(r.pending * 100)); + } + }; + + it('charges each participant their share of what the payer fronted', () => { + // Alice fronts 100 for the two of them. Nothing has been paid back yet, so the + // trip costs each of them 50: Alice is out 100 with 50 still coming, Bob is out + // nothing with 50 still to pay. + setupDb( + [makeItem(1, 100)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], + ); + const result = budget.calculateSettlement(1); + + const alice = result.finalBudgets.find(f => f.user_id === 1)!; + const bob = result.finalBudgets.find(f => f.user_id === 2)!; + expect(alice).toMatchObject({ expenses: 100, reimbursed: 0, pending: 50, final: 50 }); + expect(bob).toMatchObject({ expenses: 0, reimbursed: 0, pending: -50, final: 50 }); + checkIdentity(result.finalBudgets); + }); + + it('a recorded transfer moves out of pending and into reimbursed, leaving the final alone', () => { + // Bob pays his 50 back. What the trip costs either of them cannot change — only + // which of the two lines under it the 50 now sits on. + setupDb( + [makeItem(1, 100)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], + [makeSettlementRow(1, 2, 1, 50)], + ); + const result = budget.calculateSettlement(1); + + const alice = result.finalBudgets.find(f => f.user_id === 1)!; + const bob = result.finalBudgets.find(f => f.user_id === 2)!; + expect(alice).toMatchObject({ expenses: 100, reimbursed: 50, pending: 0, final: 50 }); + expect(bob).toMatchObject({ expenses: 0, reimbursed: -50, pending: 0, final: 50 }); + checkIdentity(result.finalBudgets); + }); + + it('sums the finals to what the trip actually spent', () => { + // 90 + 60 across three people, fronted by two of them. However the debts are + // arranged, the trip cost the group exactly what it spent. + setupDb( + [makeItem(1, 90), makeItem(2, 60)], + [ + makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), + makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol'), + ], + [makePayer(1, 1, 90, 'alice'), makePayer(2, 2, 60, 'bob')], + ); + const result = budget.calculateSettlement(1); + + expect(centSum(result.finalBudgets.map(f => f.final))).toBe(15000); + expect(result.finalBudgets.find(f => f.user_id === 1)!.final).toBe(30); + expect(result.finalBudgets.find(f => f.user_id === 2)!.final).toBe(60); + expect(result.finalBudgets.find(f => f.user_id === 3)!.final).toBe(60); + checkIdentity(result.finalBudgets); + }); + + it('follows a custom split rather than an equal one', () => { + // Alice fronts 100 but only owes 20 of it — the split says so. + setupDb( + [makeItem(1, 100)], + [ + { ...makeMember(1, 1, 'alice'), amount: 20 }, + { ...makeMember(1, 2, 'bob'), amount: 80 }, + ], + [makePayer(1, 1, 100, 'alice')], + ); + const result = budget.calculateSettlement(1); + + expect(result.finalBudgets.find(f => f.user_id === 1)!.final).toBe(20); + expect(result.finalBudgets.find(f => f.user_id === 2)!.final).toBe(80); + checkIdentity(result.finalBudgets); + }); + + it('leaves an expense nobody paid out of the final budget (#2225)', () => { + // The unpaid row stays out of the ledger, so it cannot charge anybody either. + setupDb( + [makeItem(1, 90)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [], + ); + const result = budget.calculateSettlement(1); + + expect(result.finalBudgets).toEqual([]); + }); + + it('gives a refund back to whoever was charged for it (#2176)', () => { + // A 30 refund Alice received, split between the two of them: each is 15 better + // off, so the trip costs them -15 on this row alone. + setupDb( + [makeItem(1, -30)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, -30, 'alice')], + ); + const result = budget.calculateSettlement(1); + + expect(result.finalBudgets.find(f => f.user_id === 1)!).toMatchObject({ expenses: -30, final: -15 }); + expect(result.finalBudgets.find(f => f.user_id === 2)!.final).toBe(-15); + checkIdentity(result.finalBudgets); + }); + + it('keeps the breakdown adding up in a display currency of its own', () => { + // The three lines are converted as their own sets, like the balances are, and + // the final is subtracted afterwards — so what the breakdown prints adds up in + // whatever currency the viewer picked, at whatever the live rate happens to be. + setupDb( + [makeItem(1, 100)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, 100, 'alice')], + [makeSettlementRow(1, 2, 1, 33.33)], + ); + for (const eurPerUsd of [0.855, 0.9312, 0.94]) { + const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: eurPerUsd } }); + + checkIdentity(result.finalBudgets); + // And the pending line is the balance itself, not a second opinion on it. + for (const f of result.finalBudgets) { + expect(f.pending).toBe(result.balances.find(b => b.user_id === f.user_id)!.balance); + } + } + }); + + it('costs nobody anything when a transfer has no expense behind it', () => { + // Bob handed Alice 40 with no expense on the trip to justify it. Alice has the + // 40 but owes it straight back, so the trip has cost neither of them anything — + // the transfer shows up as reimbursed on one line and outstanding on the next. + setupDb([], [], [], [makeSettlementRow(1, 2, 1, 40)]); + const result = budget.calculateSettlement(1); + + expect(result.finalBudgets.find(f => f.user_id === 1)!).toMatchObject({ expenses: 0, reimbursed: 40, pending: -40, final: 0 }); + expect(result.finalBudgets.find(f => f.user_id === 2)!).toMatchObject({ expenses: 0, reimbursed: -40, pending: 40, final: 0 }); + checkIdentity(result.finalBudgets); + }); +}); + describe('splitEqualShares — client parity (#2176)', () => { // Private on purpose (only the settlement calls it); the parity pin reaches // through so the fixture exercises the real implementation, not a re-model. diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index 895bf2768..36169f8e6 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -256,6 +256,35 @@ export const budgetUpdateSettlementRequestSchema = z.object({ }); export type BudgetUpdateSettlementRequest = z.infer; +/** + * What the trip actually costs one participant, as GET …/budget/settlement + * returns it alongside the balances and the suggested flows. + * + * `final = expenses - reimbursed - pending`, and the identity holds to the cent + * in whatever display currency was asked for: the server derives all four + * figures from the one integer-cent ledger the balances come from, so a + * breakdown can never disagree with the balance shown next to it. Every amount + * is in that display currency. + * + * A participant who fronted nothing and owes nothing is absent, exactly like + * they are from `balances` — a client listing the trip's roster fills the gap + * with zeroes rather than expecting a row per member. + */ +export const budgetParticipantFinalSchema = z.object({ + user_id: z.number(), + username: z.string(), + avatar_url: z.string().nullable(), + /** Gross outlay: what this participant fronted as a payer on split expenses. */ + expenses: z.number(), + /** Recorded settle-up transfers, netted: received minus sent. */ + reimbursed: z.number(), + /** Still to be squared up — the participant's current balance, positive when owed. */ + pending: z.number(), + /** What the trip leaves them out of pocket once everything has been settled. */ + final: z.number(), +}); +export type BudgetParticipantFinal = z.infer; + export const budgetUpdateMembersRequestSchema = z.object({ user_ids: z.array(z.number()), }); diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index dcf542afc..90a4926b0 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'يدفع', 'costs.settle': 'تسوية', 'costs.balances': 'الأرصدة', + 'costs.finalBudget': 'الميزانية النهائية', + 'costs.finalExpenses': 'المصاريف المدفوعة', + 'costs.finalReimbursed': 'المبالغ المستردة المستلمة', + 'costs.finalPending': 'المبالغ المستردة المعلقة', 'costs.byCategory': 'حسب الفئة', 'costs.noCategories': 'لا توجد مصروفات بعد.', 'costs.settleHistory': 'سجل التسويات', diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index 9f53e2188..d5717727d 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'paga', 'costs.settle': 'Acertar', 'costs.balances': 'Saldos', + 'costs.finalBudget': 'Orçamento final', + 'costs.finalExpenses': 'Despesas pagas', + 'costs.finalReimbursed': 'Reembolsos recebidos', + 'costs.finalPending': 'Reembolsos pendentes', 'costs.byCategory': 'Por categoria', 'costs.noCategories': 'Nenhuma despesa ainda.', 'costs.settleHistory': 'Histórico de acertos', diff --git a/shared/src/i18n/ca/budget.ts b/shared/src/i18n/ca/budget.ts index 19dbce180..fe83bc19b 100644 --- a/shared/src/i18n/ca/budget.ts +++ b/shared/src/i18n/ca/budget.ts @@ -78,6 +78,10 @@ const budget: TranslationStrings = { 'costs.pays': 'paga', 'costs.settle': 'Liquidar', 'costs.balances': 'Balanços', + 'costs.finalBudget': 'Pressupost final', + 'costs.finalExpenses': 'Despeses pagades', + 'costs.finalReimbursed': 'Reemborsaments rebuts', + 'costs.finalPending': 'Reemborsaments pendents', 'costs.byCategory': 'Per categoria', 'costs.noCategories': 'Encara no hi ha despeses.', 'costs.settleHistory': 'Historial de liquidacions', diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index 472a2d4da..efb48cede 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'zaplatí', 'costs.settle': 'Vyrovnat', 'costs.balances': 'Zůstatky', + 'costs.finalBudget': 'Konečný rozpočet', + 'costs.finalExpenses': 'Zaplacené výdaje', + 'costs.finalReimbursed': 'Přijaté náhrady', + 'costs.finalPending': 'Nevyřízené náhrady', 'costs.byCategory': 'Podle kategorie', 'costs.noCategories': 'Zatím žádné výdaje.', 'costs.settleHistory': 'Historie vyrovnání', diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index f96cff5be..8c78a5d41 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -83,6 +83,10 @@ const budget: TranslationStrings = { 'costs.pays': 'zahlt', 'costs.settle': 'Ausgleichen', 'costs.balances': 'Salden', + 'costs.finalBudget': 'Endbudget', + 'costs.finalExpenses': 'Bezahlte Ausgaben', + 'costs.finalReimbursed': 'Erhaltene Rückzahlungen', + 'costs.finalPending': 'Ausstehende Rückzahlungen', 'costs.byCategory': 'Nach Kategorie', 'costs.noCategories': 'Noch keine Ausgaben.', 'costs.settleHistory': 'Ausgleichs-Verlauf', diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index 3a4e8b44a..f52d761d1 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -83,6 +83,10 @@ const budget: TranslationStrings = { 'costs.pays': 'pays', 'costs.settle': 'Settle', 'costs.balances': 'Balances', + 'costs.finalBudget': 'Final budget', + 'costs.finalExpenses': 'Expenses paid', + 'costs.finalReimbursed': 'Reimbursements received', + 'costs.finalPending': 'Pending reimbursements', 'costs.byCategory': 'By category', 'costs.noCategories': 'No expenses yet.', 'costs.settleHistory': 'Settle history', diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 683da7c73..9bcb96b36 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'paga', 'costs.settle': 'Saldar', 'costs.balances': 'Saldos', + 'costs.finalBudget': 'Presupuesto final', + 'costs.finalExpenses': 'Gastos pagados', + 'costs.finalReimbursed': 'Reembolsos recibidos', + 'costs.finalPending': 'Reembolsos pendientes', 'costs.byCategory': 'Por categoría', 'costs.noCategories': 'Aún no hay gastos.', 'costs.settleHistory': 'Historial de pagos', diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index 00d50fda6..ed5874f9f 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'paie', 'costs.settle': 'Régler', 'costs.balances': 'Soldes', + 'costs.finalBudget': 'Budget final', + 'costs.finalExpenses': 'Dépenses payées', + 'costs.finalReimbursed': 'Remboursements reçus', + 'costs.finalPending': 'Remboursements en attente', 'costs.byCategory': 'Par catégorie', 'costs.noCategories': 'Aucune dépense pour le moment.', 'costs.settleHistory': 'Historique des règlements', diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index 2c38250f1..0a8d00cbc 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -81,6 +81,10 @@ const budget: TranslationStrings = { 'costs.pays': 'πληρώνει', 'costs.settle': 'Εξόφληση', 'costs.balances': 'Υπόλοιπα', + 'costs.finalBudget': 'Τελικός προϋπολογισμός', + 'costs.finalExpenses': 'Πληρωμένα έξοδα', + 'costs.finalReimbursed': 'Επιστροφές που ελήφθησαν', + 'costs.finalPending': 'Εκκρεμείς επιστροφές', 'costs.byCategory': 'Ανά κατηγορία', 'costs.noCategories': 'Δεν υπάρχουν έξοδα ακόμη.', 'costs.settleHistory': 'Ιστορικό εξοφλήσεων', diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index c7587e62c..17db51c08 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'fizet', 'costs.settle': 'Elszámol', 'costs.balances': 'Egyenlegek', + 'costs.finalBudget': 'Végső költségvetés', + 'costs.finalExpenses': 'Kifizetett kiadások', + 'costs.finalReimbursed': 'Kapott visszatérítések', + 'costs.finalPending': 'Függőben lévő visszatérítések', 'costs.byCategory': 'Kategóriánként', 'costs.noCategories': 'Még nincs költség.', 'costs.settleHistory': 'Elszámolási előzmények', diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index 8e50c7a57..eae63f69a 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'membayar', 'costs.settle': 'Lunasi', 'costs.balances': 'Saldo', + 'costs.finalBudget': 'Anggaran akhir', + 'costs.finalExpenses': 'Pengeluaran yang dibayar', + 'costs.finalReimbursed': 'Penggantian diterima', + 'costs.finalPending': 'Penggantian tertunda', 'costs.byCategory': 'Per kategori', 'costs.noCategories': 'Belum ada pengeluaran.', 'costs.settleHistory': 'Riwayat pelunasan', diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index a43b2c4d1..4baa8b520 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'paga', 'costs.settle': 'Salda', 'costs.balances': 'Saldi', + 'costs.finalBudget': 'Budget finale', + 'costs.finalExpenses': 'Spese pagate', + 'costs.finalReimbursed': 'Rimborsi ricevuti', + 'costs.finalPending': 'Rimborsi in sospeso', 'costs.byCategory': 'Per categoria', 'costs.noCategories': 'Ancora nessuna spesa.', 'costs.settleHistory': 'Cronologia saldi', diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index 99232c5aa..9e71a3351 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': '支払う', 'costs.settle': '精算', 'costs.balances': '残高', + 'costs.finalBudget': '最終負担額', + 'costs.finalExpenses': '支払った費用', + 'costs.finalReimbursed': '受け取った精算', + 'costs.finalPending': '未精算の金額', 'costs.byCategory': 'カテゴリ別', 'costs.noCategories': '支出はまだありません。', 'costs.settleHistory': '精算履歴', diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index bdce041bf..0daa3514d 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': '지불', 'costs.settle': '정산', 'costs.balances': '잔액', + 'costs.finalBudget': '최종 부담액', + 'costs.finalExpenses': '지불한 지출', + 'costs.finalReimbursed': '받은 정산', + 'costs.finalPending': '미정산 금액', 'costs.byCategory': '카테고리별', 'costs.noCategories': '아직 지출이 없습니다.', 'costs.settleHistory': '정산 내역', diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index b4d848075..37d958a6c 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'betaalt', 'costs.settle': 'Afrekenen', 'costs.balances': 'Saldi', + 'costs.finalBudget': 'Eindbudget', + 'costs.finalExpenses': 'Betaalde uitgaven', + 'costs.finalReimbursed': 'Ontvangen terugbetalingen', + 'costs.finalPending': 'Openstaande terugbetalingen', 'costs.byCategory': 'Per categorie', 'costs.noCategories': 'Nog geen uitgaven.', 'costs.settleHistory': 'Afrekengeschiedenis', diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index e6057ddde..675e92dcb 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'płaci', 'costs.settle': 'Rozlicz', 'costs.balances': 'Salda', + 'costs.finalBudget': 'Budżet końcowy', + 'costs.finalExpenses': 'Zapłacone wydatki', + 'costs.finalReimbursed': 'Otrzymane zwroty', + 'costs.finalPending': 'Oczekujące zwroty', 'costs.byCategory': 'Według kategorii', 'costs.noCategories': 'Brak wydatków.', 'costs.settleHistory': 'Historia rozliczeń', diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index d9b692e70..e248f47d7 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'платит', 'costs.settle': 'Рассчитать', 'costs.balances': 'Балансы', + 'costs.finalBudget': 'Итоговый бюджет', + 'costs.finalExpenses': 'Оплаченные расходы', + 'costs.finalReimbursed': 'Полученные возмещения', + 'costs.finalPending': 'Ожидаемые возмещения', 'costs.byCategory': 'По категориям', 'costs.noCategories': 'Расходов пока нет.', 'costs.settleHistory': 'История расчётов', diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index 2be809959..2e47db018 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'betalar', 'costs.settle': 'Lösa', 'costs.balances': 'Balanser', + 'costs.finalBudget': 'Slutlig budget', + 'costs.finalExpenses': 'Betalda utgifter', + 'costs.finalReimbursed': 'Mottagna återbetalningar', + 'costs.finalPending': 'Väntande återbetalningar', 'costs.byCategory': 'Via kategori', 'costs.noCategories': 'Inga utgifter än.', 'costs.settleHistory': 'Historik över reglering', diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index 36630158c..1f0d2c5b8 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'ödüyor', 'costs.settle': 'Hesaplaş', 'costs.balances': 'Bakiyeler', + 'costs.finalBudget': 'Nihai bütçe', + 'costs.finalExpenses': 'Ödenen harcamalar', + 'costs.finalReimbursed': 'Alınan geri ödemeler', + 'costs.finalPending': 'Bekleyen geri ödemeler', 'costs.byCategory': 'Kategoriye göre', 'costs.noCategories': 'Henüz harcama yok.', 'costs.settleHistory': 'Hesaplaşma geçmişi', diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 875c180b7..8f714b1bc 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'платить', 'costs.settle': 'Розрахувати', 'costs.balances': 'Баланси', + 'costs.finalBudget': 'Підсумковий бюджет', + 'costs.finalExpenses': 'Оплачені витрати', + 'costs.finalReimbursed': 'Отримані відшкодування', + 'costs.finalPending': 'Очікувані відшкодування', 'costs.byCategory': 'За категоріями', 'costs.noCategories': 'Витрат ще немає.', 'costs.settleHistory': 'Історія розрахунків', diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index 45b17c136..2007c1ff7 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -80,6 +80,10 @@ const budget: TranslationStrings = { 'costs.pays': 'trả tiền', 'costs.settle': 'Thanh toán', 'costs.balances': 'Số dư', + 'costs.finalBudget': 'Ngân sách cuối cùng', + 'costs.finalExpenses': 'Chi phí đã trả', + 'costs.finalReimbursed': 'Khoản hoàn trả đã nhận', + 'costs.finalPending': 'Khoản hoàn trả đang chờ', 'costs.byCategory': 'Theo danh mục', 'costs.noCategories': 'Chưa có chi phí nào.', 'costs.settleHistory': 'Lịch sử thanh toán', diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index b7c2cf1f0..b8a166e7e 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -79,6 +79,10 @@ const budget: TranslationStrings = { 'costs.pays': '支付', 'costs.settle': '結算', 'costs.balances': '餘額', + 'costs.finalBudget': '最終花費', + 'costs.finalExpenses': '已支付的支出', + 'costs.finalReimbursed': '已收到的還款', + 'costs.finalPending': '待還款項', 'costs.byCategory': '按分類', 'costs.noCategories': '尚無支出。', 'costs.settleHistory': '結算紀錄', diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index 83eca4be1..c31b973de 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -79,6 +79,10 @@ const budget: TranslationStrings = { 'costs.pays': '支付', 'costs.settle': '结算', 'costs.balances': '余额', + 'costs.finalBudget': '最终花费', + 'costs.finalExpenses': '已支付的支出', + 'costs.finalReimbursed': '已收到的还款', + 'costs.finalPending': '待还款项', 'costs.byCategory': '按分类', 'costs.noCategories': '暂无支出。', 'costs.settleHistory': '结算历史', diff --git a/wiki/Budget-Tracking.md b/wiki/Budget-Tracking.md index 0ded1d389..520d7bd09 100644 --- a/wiki/Budget-Tracking.md +++ b/wiki/Budget-Tracking.md @@ -83,10 +83,13 @@ An expense with no payer is flagged **Unfinished** on its row and counted into t ## Settlement calculator -Costs works out the minimum number of transfers needed to settle all debts (using a greedy matching algorithm) and keeps the answer in the right-hand column, split across two cards: +Costs works out the minimum number of transfers needed to settle all debts (using a greedy matching algorithm) and keeps the answer in the right-hand column, split across three cards: - **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. + +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. **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 f1be75936..9baffb31e 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 and the suggested payments to settle shared expenses, 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, reimbursements received, reimbursements pending, final cost), 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). |