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