feat(costs): Splitwise-like cost splitting

Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.
This commit is contained in:
Zorth Thorch
2026-06-29 19:25:15 +02:00
committed by Maurice
parent 3701ab6cad
commit e34f40b686
30 changed files with 619 additions and 150 deletions
@@ -91,7 +91,7 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Dinner')).toBeInTheDocument()
})
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
it('supports custom split amounts on save', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
@@ -108,18 +108,22 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50'))
expect(nums()[2].value).toBe('50')
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
await user.clear(nums()[1]); await user.type(nums()[1], '30')
await waitFor(() => expect(nums()[2].value).toBe('70'))
await user.type(nums()[0], '100') // total = 100
await user.click(screen.getByRole('button', { name: /Custom/i }))
const customInputs = screen.getAllByPlaceholderText('50.00')
await user.type(customInputs[0], '30')
await user.type(customInputs[1], '70')
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.payers).toEqual(expect.arrayContaining([
expect(posted!.payers).toEqual([
expect.objectContaining({ amount: 100 })
])
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 30 }),
expect.objectContaining({ user_id: 2, amount: 70 }),
]))
@@ -194,4 +198,60 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
it('supports itemized receipt ticket manual entry and split assignment', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 10 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.click(screen.getByRole('button', { name: 'Ticket' }))
const addBtn = screen.getByRole('button', { name: /Add item/i })
await user.click(addBtn)
await user.click(addBtn)
await user.click(addBtn)
const itemNames = screen.getAllByPlaceholderText('Item name')
const itemPrices = screen.getAllByPlaceholderText('0.00')
await user.type(itemNames[0], 'Apples')
await user.type(itemPrices[1], '10')
await user.type(itemNames[1], 'chocolate cake')
await user.type(itemPrices[2], '50')
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
await user.click(bobButtons[1])
await user.type(itemNames[2], 'Milk')
await user.type(itemPrices[3], '40')
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 75 }),
expect.objectContaining({ user_id: 2, amount: 25 }),
]))
expect(posted!.note).toContain('TICKETJSON:')
})
})
+389 -98
View File
@@ -19,6 +19,68 @@ import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
if (n === 0) return {}
const totalCents = Math.round(total * 100)
const baseCents = Math.floor(totalCents / n)
const remainder = totalCents % n
const shares: Record<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
const startIndex = itemId % n
for (let i = 0; i < n; i++) {
const member = sortedMembers[i]
const hasExtraCent = ((i - startIndex + n) % n) < remainder
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
}
return shares
}
export interface TicketItem {
id: string
name: string
price: string
participants: Set<number>
}
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
let totalCents = 0
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
const partIds = [...item.participants]
const n = partIds.length
if (n === 0) continue
const baseCents = Math.floor(priceCents / n)
const remainder = priceCents % n
const sortedPartIds = [...partIds].sort((a, b) => a - b)
for (let i = 0; i < n; i++) {
const id = sortedPartIds[i]
const hasExtraCent = i < remainder
const shareCents = baseCents + (hasExtraCent ? 1 : 0)
shares[id] = (shares[id] || 0) + shareCents
}
}
const finalShares: Record<number, number> = {}
for (const id of Object.keys(shares)) {
finalShares[Number(id)] = shares[Number(id)] / 100
}
return { shares: finalShares, total: totalCents / 100 }
}
interface CostsPanelProps {
tripId: number
tripMembers?: TripMember[]
@@ -105,9 +167,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
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 myShareOf = (e: BudgetItem) => {
const n = (e.members || []).length
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
return baseTotal(e) / n
const myMember = (e.members || []).find(m => m.user_id === me)
if (!myMember) return 0
if (myMember.amount !== null && myMember.amount !== undefined) {
return convert(myMember.amount, curOf(e))
}
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
}
const totals = useMemo(() => {
@@ -790,11 +857,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
// One participant list: a person is "in" the split and may have paid an amount.
// Entering the total auto-distributes it equally across the non-pinned participants;
// touching an amount pins it and the rest rebalance so the paid amounts always sum
// back to the total. Leaving every amount blank = an unfinished expense (counts
// toward the trip total only, never settlements, until who-paid is filled in).
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
@@ -802,89 +864,192 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
})
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [paid, setPaid] = useState<Record<number, string>>(() => {
// Payer state: 0 represents "Nobody (planning entry)"
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
return existingPayer ? existingPayer.user_id : me
})
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
return 'ticket'
}
if (editing && editing.members && editing.members.length > 0) {
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
return hasCustom ? 'custom' : 'equally'
}
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: any) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set(item.parts || [])
}))
} catch {
return []
}
}
return []
})
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
if (editing && editing.members) {
for (const member of editing.members) {
if (member.amount !== null && member.amount !== undefined) {
m[member.user_id] = String(member.amount)
}
}
}
return m
})
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
// payer amounts load as pinned so opening an expense never reshuffles them.
const [dirty, setDirty] = useState<Set<number>>(() =>
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
const [saving, setSaving] = useState(false)
const totalNum = parseFloat(total) || 0
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
const paidEntered = paidSum > 0
const balanced = Math.abs(paidSum - totalNum) < 0.01
const each = participants.size > 0 ? totalNum / participants.size : 0
// No participants = a recorded total with nobody to split with (e.g. a booking
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
// people only adds the who-owes-whom split on top.
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
const isTicketMode = splitMode === 'ticket'
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const splitCents = (amount: number, n: number): number[] => {
if (n <= 0) return []
const cents = Math.max(0, Math.round(amount * 100))
const base = Math.floor(cents / n), rem = cents - base * n
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
// Recompute the non-pinned participants so every paid amount sums to the total.
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
const ids = [...parts]
const free = ids.filter(id => !dirtySet.has(id))
if (free.length === 0) return paidMap
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
const shares = splitCents(totalVal - pinnedSum, free.length)
const next = { ...paidMap }
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
return next
}
const ticketInfo = useMemo(() => {
return calculateTicketShares(ticketItems)
}, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => {
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, editing])
const placeholderShares = useMemo(() => {
const emptyParts = [...participants].filter(id => !customAmounts[id])
if (emptyParts.length === 0) return {}
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, customAmounts, editing])
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const valid = name.trim().length > 0 && (
isTicketMode
? ticketValid
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
)
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
setTotal(v.replace(',', '.'))
}
const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
const handleCustomAmountChange = (id: number, val: string) => {
val = val.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
setCustomAmounts(prev => ({ ...prev, [id]: val }))
}
}
const handleAddEmptyItem = () => {
setTicketItems(prev => [
...prev,
{
id: String(Date.now() + Math.random()),
name: '',
price: '',
participants: new Set(people.map(p => p.id))
}
])
}
const handleUpdateItemName = (id: string, name: string) => {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item))
}
const handleUpdateItemPrice = (id: string, price: string) => {
price = price.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
}
}
const handleRemoveItem = (id: string) => {
setTicketItems(prev => prev.filter(item => item.id !== id))
}
const handleToggleItemParticipant = (itemId: string, userId: number) => {
setTicketItems(prev => prev.map(item => {
if (item.id === itemId) {
const nextParts = new Set(item.participants)
if (nextParts.has(userId)) nextParts.delete(userId)
else nextParts.add(userId)
return { ...item, participants: nextParts }
}
return item
}))
}
const toggleParticipant = (id: number) => {
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
else nextParts.add(id)
setParticipants(nextParts); setDirty(nextDirty)
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
const nextParts = new Set(participants)
if (nextParts.has(id)) {
nextParts.delete(id)
setCustomAmounts(prev => {
const copy = { ...prev }
delete copy[id]
return copy
})
} else {
nextParts.add(id)
}
setParticipants(nextParts)
}
const save = async () => {
if (!valid) return
setSaving(true)
const payerList = [...participants]
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
.filter(p => p.amount > 0)
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
const memberList = [...participants].map(id => ({
user_id: id,
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
: splitMode === 'ticket'
? (ticketInfo.shares[id] || 0)
: null
}))
const data = {
name: name.trim(), category: cat,
// Store the actual currency the amounts were entered in; conversion to the
// viewer's display currency happens live (real rates), no manual rate.
name: name.trim(),
category: cat,
currency,
payers: payerList, member_ids: [...participants],
payers: payerList,
members: memberList,
member_ids: [...participants],
expense_date: day || null,
// Always record the entered total: the server keeps it as-is for an unfinished
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
total_price: totalNum,
// Link a freshly-created expense to its booking (create-from-booking flow).
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
items: ticketItems.map(item => ({
name: item.name,
price: item.price,
parts: [...item.participants]
}))
}) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
onSaved()
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
} catch {
toast.error(t('common.unknownError'))
} finally {
setSaving(false)
}
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
@@ -906,10 +1071,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div>
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
<input type="text" inputMode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
onChange={e => onTotalChange(e.target.value)}
disabled={isTicketMode}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div>
</div>
@@ -954,39 +1120,164 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div>
<label className={labelCls}>{t('costs.whoPaid')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
</button>
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
options={[
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
]}
style={{ width: '100%' }} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
<button type="button" onClick={() => setSplitMode('equally')}
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitEqually') || 'Equally'}
</button>
<button type="button" onClick={() => setSplitMode('custom')}
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitCustom') || 'Custom'}
</button>
<button type="button" onClick={() => setSplitMode('ticket')}
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitTicket') || 'Ticket'}
</button>
</div>
</div>
{splitMode === 'ticket' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ticketItems.map((item, itemIdx) => (
<div key={item.id} className="bg-surface-secondary border border-edge" style={{ padding: 10, borderRadius: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Item name"
value={item.name}
onChange={e => handleUpdateItemName(item.id, e.target.value)}
className="bg-surface-input border border-edge text-content"
style={{ flex: 2, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
/>
<div className="bg-surface-input border border-edge" style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={item.price}
onChange={e => handleUpdateItemPrice(item.id, e.target.value)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
/>
</div>
<button type="button" onClick={() => handleRemoveItem(item.id)} className="text-content-muted" style={{ background: 'none', border: 0, cursor: 'pointer', padding: 4 }}>
<Trash2 size={15} />
</button>
</div>
) : (
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
{people.map((p, pIdx) => {
const active = item.participants.has(p.id)
return (
<button
type="button"
key={p.id}
onClick={() => handleToggleItemParticipant(item.id, p.id)}
className={active ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: active ? '1px solid var(--text-primary)' : undefined }}
>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 14, height: 14, borderRadius: '50%', objectFit: 'cover' }} />
: <span style={{ width: 14, height: 14, borderRadius: '50%', background: SPLIT_COLORS[pIdx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 7, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span>{p.id === me ? t('costs.you') : p.username}</span>
</button>
)
})}
</div>
</div>
))}
</div>
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Plus size={14} /> Add item
</button>
{ticketItems.length > 0 && (
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{people.map(p => {
const share = ticketInfo.shares[p.id] || 0
return (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span className="text-content-muted">{p.id === me ? t('costs.you') : p.username}</span>
<span className="text-content" style={{ fontWeight: 600 }}>{sym(currency)}{share.toFixed(2)}</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
{paidEntered
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
</div>
)}
</div>
) : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button type="button" onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
</button>
{splitMode === 'equally' ? (
on ? (
<span className="text-content" style={{ fontSize: 14, fontWeight: 600, textAlign: 'right', paddingRight: 10 }}>
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
</span>
) : (
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
)
) : (
on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
) : (
<button type="button" onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)
)}
</div>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
{splitMode === 'equally' ? (
<span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
) : (
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? 'Split matches total'
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
</span>
)}
</div>
</>
)}
</div>
</div>
</Modal>
+1 -1
View File
@@ -52,7 +52,7 @@ describe('budgetSlice', () => {
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
)
);
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
await expect(useTripStore.getState().addBudgetItem(1, { name: 'x' })).rejects.toThrow();
});
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
+4 -4
View File
@@ -12,8 +12,8 @@ type GetState = StoreApi<TripStoreState>['getState']
export interface BudgetSlice {
loadBudgetItems: (tripId: number | string) => Promise<void>
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
addBudgetItem: (tripId: number | string, data: BudgetCreateItemRequest) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => Promise<BudgetItem>
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetItemMember[]; item: BudgetItem }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
@@ -33,7 +33,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data as BudgetCreateItemRequest)
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -43,7 +43,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data as BudgetUpdateItemRequest)
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
+7
View File
@@ -3071,6 +3071,13 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
() => {
try {
db.exec('ALTER TABLE budget_item_members ADD COLUMN amount REAL');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
];
if (currentVersion < migrations.length) {
+51 -18
View File
@@ -11,7 +11,7 @@ export { verifyTripAccess } from './tripAccess';
function loadItemMembers(itemId: number | string) {
const rows = db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar
SELECT bm.user_id, bm.paid, bm.amount, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ?
@@ -60,7 +60,7 @@ export function listBudgetItems(tripId: string | number) {
if (itemIds.length > 0) {
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
@@ -69,7 +69,7 @@ export function listBudgetItems(tripId: string | number) {
for (const m of allMembers) {
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
membersByItem[m.budget_item_id].push({
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m),
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m), amount: m.amount,
});
}
}
@@ -104,6 +104,7 @@ export function createBudgetItem(
category?: string; name: string; total_price?: number;
currency?: string | null; exchange_rate?: number;
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
members?: { user_id: number; amount?: number | null }[];
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
reservation_id?: number | null;
},
@@ -147,8 +148,11 @@ export function createBudgetItem(
const itemId = result.lastInsertRowid as number;
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
if (data.member_ids && data.member_ids.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
if (data.members && data.members.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
for (const m of data.members) insert.run(itemId, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
} else if (data.member_ids && data.member_ids.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
for (const uid of data.member_ids) insert.run(itemId, uid);
}
@@ -185,6 +189,7 @@ export function updateBudgetItem(
category?: string; name?: string; total_price?: number;
currency?: string | null; exchange_rate?: number;
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
members?: { user_id: number; amount?: number | null }[];
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
},
) {
@@ -228,9 +233,14 @@ export function updateBudgetItem(
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
}
}
if (data.member_ids !== undefined) {
if (data.members !== undefined) {
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
for (const m of data.members) insert.run(id, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.members.length || null, id);
} else if (data.member_ids !== undefined) {
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
for (const uid of data.member_ids) insert.run(id, uid);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
}
@@ -323,8 +333,8 @@ export function toggleMemberPaid(id: string | number, tripId: string | number, u
export function getPerPersonSummary(tripId: string | number) {
const summary = db.prepare(`
SELECT bm.user_id, u.username, u.avatar,
SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
COUNT(bi.id) as items_count
FROM budget_item_members bm
JOIN budget_items bi ON bm.budget_item_id = bi.id
@@ -336,9 +346,26 @@ export function getPerPersonSummary(tripId: string | number) {
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
}
// ---------------------------------------------------------------------------
// Settlement calculation (greedy debt matching)
// ---------------------------------------------------------------------------
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length;
if (n === 0) return {};
const totalCents = Math.round(total * 100);
const baseCents = Math.floor(totalCents / n);
const remainder = totalCents % n;
const shares: Record<number, number> = {};
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id);
const startIndex = itemId % n;
for (let i = 0; i < n; i++) {
const member = sortedMembers[i];
const hasExtraCent = ((i - startIndex + n) % n) < remainder;
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100;
}
return shares;
}
export function calculateSettlement(
tripId: string | number,
@@ -392,13 +419,19 @@ export function calculateSettlement(
const payers = allPayers.filter(p => p.budget_item_id === item.id);
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate), 0);
const sharePerMember = paidBase / members.length;
// Payers are credited what they actually paid (converted to base)…
// Payers are credited what they actually paid (converted to base with the
// item's stored exchange rate)…
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
// …and every split participant owes an equal share of the base total.
for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
// …and each split participant owes their share — a custom per-member amount
// when one is set, otherwise an equal share of the expense total.
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
const equalShares = !hasCustomSplit ? splitEqualShares(item.total_price, members, item.id) : {};
for (const m of members) {
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
? toBase(m.amount, item.currency, item.exchange_rate)
: toBase(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
ensure(m.user_id, m).balance -= memberShare;
}
}
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
+1
View File
@@ -144,6 +144,7 @@ export interface BudgetItemMember {
avatar_url?: string | null;
avatar?: string | null;
budget_item_id?: number;
amount?: number | null;
}
export interface BudgetItemPayer {
+8
View File
@@ -24,6 +24,7 @@ export const budgetItemMemberSchema = z.object({
avatar_url: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
budget_item_id: z.number().optional(),
amount: z.number().nullable().optional(),
});
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
@@ -126,6 +127,11 @@ const payerInputSchema = z.object({
amount: z.number(),
});
const memberInputSchema = z.object({
user_id: z.number(),
amount: z.number().nullable().optional(),
});
export const budgetCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
@@ -137,6 +143,7 @@ export const budgetCreateItemRequestSchema = z.object({
payers: z.array(payerInputSchema).optional(),
// Equal-split participants. When omitted, the item has no split (planning-only).
member_ids: z.array(z.number()).optional(),
members: z.array(memberInputSchema).optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
@@ -156,6 +163,7 @@ export const budgetUpdateItemRequestSchema = z.object({
exchange_rate: z.number().optional(),
payers: z.array(payerInputSchema).optional(),
member_ids: z.array(z.number()).optional(),
members: z.array(memberInputSchema).optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'في الإجمالي فقط — لم تتم التسوية بعد',
'costs.tapToInclude': 'اضغط للتضمين',
'costs.amount': 'المبلغ',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Apenas no total — ainda não acertado',
'costs.tapToInclude': 'Toque para incluir',
'costs.amount': 'Valor',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Jen v součtu — zatím nevyrovnáno',
'costs.tapToInclude': 'Klepnutím zahrnout',
'costs.amount': 'Částka',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Nur Gesamtsumme — noch nicht abgerechnet',
'costs.tapToInclude': 'Zum Einbeziehen tippen',
'costs.amount': 'Betrag',
'costs.split': "Aufteilen",
'costs.splitEqually': "Gleichmäßig",
'costs.splitCustom': "Individuell",
'costs.splitTicket': "Beleg",
};
export default budget;
+4
View File
@@ -91,6 +91,10 @@ const budget: TranslationStrings = {
'costs.category': 'Category',
'costs.whoPaid': 'Who paid?',
'costs.splitBetween': 'Split equally between',
'costs.split': 'Split',
'costs.splitEqually': 'Equally',
'costs.splitCustom': 'Custom',
'costs.splitTicket': 'Ticket',
'costs.pickSomeone': 'Pick at least one person to split with.',
'costs.splitSummary': 'Split {count} ways · {amount} each',
'costs.cat.accommodation': 'Accommodation',
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Solo en el total — aún sin saldar',
'costs.tapToInclude': 'Toca para incluir',
'costs.amount': 'Importe',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Total seulement — pas encore réglé',
'costs.tapToInclude': 'Toucher pour inclure',
'costs.amount': 'Montant',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -117,6 +117,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Μόνο στο σύνολο — δεν έχει διακανονιστεί',
'costs.tapToInclude': 'Πατήστε για προσθήκη',
'costs.amount': 'Ποσό',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Csak az összegben — még nincs rendezve',
'costs.tapToInclude': 'Koppintson a hozzáadáshoz',
'costs.amount': 'Összeg',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Hanya total — belum diselesaikan',
'costs.tapToInclude': 'Ketuk untuk menyertakan',
'costs.amount': 'Jumlah',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Solo nel totale — non ancora saldato',
'costs.tapToInclude': 'Tocca per includere',
'costs.amount': 'Importo',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '合計のみ — 未精算',
'costs.tapToInclude': 'タップして追加',
'costs.amount': '金額',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '합계에만 반영 — 아직 미정산',
'costs.tapToInclude': '탭하여 포함',
'costs.amount': '금액',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Alleen in totaal — nog niet verrekend',
'costs.tapToInclude': 'Tik om toe te voegen',
'costs.amount': 'Bedrag',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Tylko w sumie — jeszcze nierozliczone',
'costs.tapToInclude': 'Dotknij, aby dodać',
'costs.amount': 'Kwota',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Только в сумме — ещё не урегулировано',
'costs.tapToInclude': 'Нажмите, чтобы добавить',
'costs.amount': 'Сумма',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Endast totalt – ännu inte reglerat',
'costs.tapToInclude': 'Tryck för att inkludera',
'costs.amount': 'Summa',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Yalnızca toplamda — henüz ödenmedi',
'costs.tapToInclude': 'Eklemek için dokun',
'costs.amount': 'Tutar',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Лише в сумі — ще не врегульовано',
'costs.tapToInclude': 'Натисніть, щоб додати',
'costs.amount': 'Сума',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -115,6 +115,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '僅計入總額 — 尚未結算',
'costs.tapToInclude': '點按以加入',
'costs.amount': '金額',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -115,6 +115,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '仅计入总额 — 尚未结算',
'costs.tapToInclude': '点按以加入',
'costs.amount': '金额',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+6 -1
View File
@@ -54,7 +54,12 @@ Add a new item using the inline **add row** at the bottom of each category table
The **Persons** column behaves differently depending on the trip:
- **Single-user trip** — enter a number of persons directly.
- **Multi-member trip** — a member chip picker appears. Click the edit button to assign or remove members from an expense. Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
- **Multi-member trip** — a member chip picker appears. Click the edit button to open the expense modal, where you can select:
- **Equally** — Splits the cost equally among selected members. Remainder cents (from rounding errors) are distributed deterministically and rotated using the item ID to ensure everyone is charged equally over the course of the trip.
- **Custom** — Enter specific custom amounts for each traveler. The sum of the custom splits must balance exactly to the total price.
- **Ticket** — Build an itemized list of expenses (e.g. Apples: $10, cake: $50, Milk: $40) and assign specific trip participants to split each individual item. Individual shares are calculated cent-perfectly, the total expense price is automatically summed, and the list of itemized splits is saved/restored across edits.
Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
![Add Expense](assets/BudgetAddExpensive.gif)