From e0c9a8d1b65cdfa89e17e5c342821afe11c4b2a5 Mon Sep 17 00:00:00 2001 From: RemEx <74838147+Rem7474@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:15:31 +0000 Subject: [PATCH] feat(costs): show what the trip finally costs each participant The settle-up view says who owes whom, but nothing on the Costs screen answers "what did the trip cost me": the balances move every time a payment is recorded, and the tiles only describe the signed-in user. The settlement response now carries finalBudgets, one row per participant in the ledger: expenses (what they fronted on split expenses), reimbursed (recorded transfers, received minus sent), pending (their current balance) and final = expenses - reimbursed - pending. - Computed in calculateSettlement from the same integer trip cents as the balances, filled by the same loops, so the breakdown can never contradict the balance printed next to it. Each component is converted to the display currency as its own set and the final is subtracted in display cents, so the three lines add up to the total in any currency. - A "Final budget" card on desktop, the narrow desktop layout and the mobile Costs tab: one amount per traveler, and a tap opens the three lines plus the expenses, transfers and open flows behind them. The filtering lives in CostsPanel.helpers so both shells share it. - Contract in shared (budgetParticipantFinalSchema); the MCP get_settlement_summary description mentions the new field it returns. - Four new costs.* keys, translated in every locale. --- .../Budget/CostsPanel.helpers.test.ts | 62 +++++++- .../components/Budget/CostsPanel.helpers.ts | 46 ++++++ .../src/components/Budget/CostsPanel.test.tsx | 62 +++++++- client/src/components/Budget/CostsPanel.tsx | 129 ++++++++++++++- .../mobile/screens/trip/tabs/MCostsTab.tsx | 85 +++++++++- .../mobile/screens/trip/tabs/costsModel.ts | 15 +- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 37 +++++ server/src/nest/budget/budget.mcp.ts | 2 +- server/src/nest/budget/budget.service.ts | 42 ++++- .../unit/nest/budget.service.calc.test.ts | 150 ++++++++++++++++++ shared/src/budget/budget.schema.ts | 29 ++++ shared/src/i18n/ar/budget.ts | 4 + shared/src/i18n/br/budget.ts | 4 + shared/src/i18n/ca/budget.ts | 4 + shared/src/i18n/cs/budget.ts | 4 + shared/src/i18n/de/budget.ts | 4 + shared/src/i18n/en/budget.ts | 4 + shared/src/i18n/es/budget.ts | 4 + shared/src/i18n/fr/budget.ts | 4 + shared/src/i18n/gr/budget.ts | 4 + shared/src/i18n/hu/budget.ts | 4 + shared/src/i18n/id/budget.ts | 4 + shared/src/i18n/it/budget.ts | 4 + shared/src/i18n/ja/budget.ts | 4 + shared/src/i18n/ko/budget.ts | 4 + shared/src/i18n/nl/budget.ts | 4 + shared/src/i18n/pl/budget.ts | 4 + shared/src/i18n/ru/budget.ts | 4 + shared/src/i18n/sv/budget.ts | 4 + shared/src/i18n/tr/budget.ts | 4 + shared/src/i18n/uk/budget.ts | 4 + shared/src/i18n/vi/budget.ts | 4 + shared/src/i18n/zh-TW/budget.ts | 4 + shared/src/i18n/zh/budget.ts | 4 + wiki/Budget-Tracking.md | 5 +- wiki/MCP-Tools-and-Resources.md | 2 +- 36 files changed, 735 insertions(+), 23 deletions(-) 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). |