diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 75c6a8e..346b7d8 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -184,6 +184,7 @@ export const budgetApi = {
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
+ settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
}
export const filesApi = {
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index 171375f..ba59f0d 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
-import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
+import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import type { BudgetItem, BudgetMember } from '../../types'
@@ -145,9 +145,11 @@ interface ChipWithTooltipProps {
label: string
avatarUrl: string | null
size?: number
+ paid?: boolean
+ onClick?: () => void
}
-function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
+function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null)
@@ -160,13 +162,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
setHover(true)
}
+ const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
+ const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
+
return (
<>
setHover(false)}
+ onClick={onClick}
style={{
- width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
- background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
- fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
+ width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
+ background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
+ overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
+ transition: 'border-color 0.15s, background 0.15s',
}}>
{avatarUrl
?

@@ -177,11 +185,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
{label}
+ {paid && (
+ Paid
+ )}
,
document.body
)}
@@ -194,10 +210,11 @@ interface BudgetMemberChipsProps {
members?: BudgetMember[]
tripMembers?: TripMember[]
onSetMembers: (memberIds: number[]) => void
+ onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
}
-function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
+function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
const chipSize = compact ? 20 : 30
const btnSize = compact ? 18 : 28
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
@@ -237,7 +254,10 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
return (
{members.map(m => (
-
+ onTogglePaid(m.user_id, !m.paid) : undefined}
+ />
))}
@@ -553,6 +582,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
+ onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
/>
) : (
handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
@@ -628,6 +658,91 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
)}
+
+ {/* Settlement dropdown inside the total card */}
+ {hasMultipleMembers && settlement && settlement.flows.length > 0 && (
+
+
+
+ {settlementOpen && (
+
+ {settlement.flows.map((flow, i) => (
+
+
+
+ →
+
+ {fmt(flow.amount, currency)}
+
+ →
+
+
+
+ ))}
+
+ {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
+
+
+ {t('budget.netBalances')}
+
+ {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
+
+
+ {b.avatar_url
+ ?

+ : b.username?.[0]?.toUpperCase()
+ }
+
+
+ {b.username}
+
+
0 ? '#4ade80' : '#f87171',
+ }}>
+ {b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )}
{pieSegments.length > 0 && (
@@ -641,27 +756,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
-
+
{pieSegments.map(seg => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return (
{seg.name}
-
{pct}%
+
{fmt(seg.value, currency)}
+
{pct}%
)
})}
-
-
- {pieSegments.map(seg => (
-
- {seg.name}
- {fmt(seg.value, currency)}
-
- ))}
-
)}
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 09c82d1..b09273e 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -800,6 +800,9 @@ const de: Record = {
'budget.paid': 'Bezahlt',
'budget.open': 'Offen',
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
+ 'budget.settlement': 'Ausgleich',
+ 'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
+ 'budget.netBalances': 'Netto-Salden',
// Files
'files.title': 'Dateien',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 21ca7a6..b85b6dc 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -800,6 +800,9 @@ const en: Record = {
'budget.paid': 'Paid',
'budget.open': 'Open',
'budget.noMembers': 'No members assigned',
+ 'budget.settlement': 'Settlement',
+ 'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
+ 'budget.netBalances': 'Net Balances',
// Files
'files.title': 'Files',
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index f62499d..2254b21 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -325,6 +325,10 @@ function runMigrations(db: Database.Database): void {
// Add day_plan_position to reservations for persistent transport ordering in day timeline
try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {}
},
+ () => {
+ // Add paid_by_user_id to budget_items for expense tracking / settlement
+ try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {}
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts
index c1b9258..410f62b 100644
--- a/server/src/routes/budget.ts
+++ b/server/src/routes/budget.ts
@@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
});
+// Settlement calculation: who owes whom
+router.get('/settlement', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+ if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
+ const allMembers = db.prepare(`
+ SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
+ FROM budget_item_members bm
+ JOIN users u ON bm.user_id = u.id
+ WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
+ `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
+
+ // Calculate net balance per user: positive = is owed money, negative = owes money
+ const balances: Record = {};
+
+ for (const item of items) {
+ const members = allMembers.filter(m => m.budget_item_id === item.id);
+ if (members.length === 0) continue;
+
+ const payers = members.filter(m => m.paid);
+ if (payers.length === 0) continue; // no one marked as paid
+
+ const sharePerMember = item.total_price / members.length;
+ const paidPerPayer = item.total_price / payers.length;
+
+ for (const m of members) {
+ if (!balances[m.user_id]) {
+ balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
+ }
+ // Everyone owes their share
+ balances[m.user_id].balance -= sharePerMember;
+ // Payers get credited what they paid
+ if (m.paid) balances[m.user_id].balance += paidPerPayer;
+ }
+ }
+
+ // Calculate optimized payment flows (greedy algorithm)
+ const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
+ const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
+ const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
+
+ // Sort by amount descending for efficient matching
+ debtors.sort((a, b) => b.amount - a.amount);
+ creditors.sort((a, b) => b.amount - a.amount);
+
+ const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
+
+ let di = 0, ci = 0;
+ while (di < debtors.length && ci < creditors.length) {
+ const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
+ if (transfer > 0.01) {
+ flows.push({
+ from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
+ to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
+ amount: Math.round(transfer * 100) / 100,
+ });
+ }
+ debtors[di].amount -= transfer;
+ creditors[ci].amount -= transfer;
+ if (debtors[di].amount < 0.01) di++;
+ if (creditors[ci].amount < 0.01) ci++;
+ }
+
+ res.json({
+ balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
+ flows,
+ });
+});
+
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;