diff --git a/Dockerfile b/Dockerfile
index d6463fd7..e6e8f1a2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -85,6 +85,10 @@ COPY --from=server-builder /app/server/dist ./server/dist
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
+# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
+# raw .ts source — it never enters dist, so it must be copied in explicitly or
+# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
+COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 899453c8..dab6ba43 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -489,7 +489,7 @@ export const addonsApi = {
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
- saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
+ saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
@@ -595,6 +595,7 @@ export const budgetApi = {
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
+ updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx
index 5baa85fa..d984991b 100644
--- a/client/src/components/Admin/DefaultUserSettingsTab.tsx
+++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
+import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import type { Place } from '../../types'
const MAP_PRESETS = [
@@ -20,6 +21,7 @@ type Defaults = {
temperature_unit?: string
dark_mode?: string | boolean
time_format?: string
+ default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
map_provider?: string
@@ -226,6 +228,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
+ {/* Default Currency */}
+
+
+
{ if (value) save({ default_currency: value }) }}
+ placeholder={t('settings.currency')}
+ searchable
+ options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
+ size="sm"
+ style={{ maxWidth: 240 }}
+ />
+ {t('settings.currencyHint')}
+
+
{/* Blur Booking Codes */}
{t('settings.blurBookingCodes')} >}>
{([
diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx
new file mode 100644
index 00000000..1d3fe503
--- /dev/null
+++ b/client/src/components/Budget/CostsPanel.test.tsx
@@ -0,0 +1,138 @@
+// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
+import { render, screen, waitFor } from '../../../tests/helpers/render'
+import { http, HttpResponse } from 'msw'
+import { server } from '../../../tests/helpers/msw/server'
+import { useAuthStore } from '../../store/authStore'
+import { useTripStore } from '../../store/tripStore'
+import { resetAllStores, seedStore } from '../../../tests/helpers/store'
+import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
+import CostsPanel from './CostsPanel'
+
+const tripMembers = [
+ { id: 1, username: 'alice', avatar_url: null },
+ { id: 2, username: 'bob', avatar_url: null },
+]
+
+beforeEach(() => {
+ resetAllStores()
+ seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
+ seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
+})
+
+describe('CostsPanel — settlements in the ledger', () => {
+ it('renders a settle-up payment as a ledger row with an undo action', async () => {
+ const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
+ server.use(
+ http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
+ http.get('/api/trips/1/budget/settlement', () =>
+ HttpResponse.json({
+ balances: [],
+ flows: [],
+ settlements: [
+ { id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
+ ],
+ })
+ ),
+ )
+ render()
+
+ // The expense and the settlement (payment) both appear in the unified ledger.
+ await screen.findByText('Dinner')
+ await screen.findByText('Payment')
+ // The payment row exposes an inline undo (no need to open a separate History modal).
+ expect(screen.getByTitle('Undo')).toBeInTheDocument()
+ })
+
+ it('records a manual payment via the Add payment button', async () => {
+ let posted: Record | 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/settlements', async ({ request }) => {
+ posted = await request.json() as Record
+ return HttpResponse.json({ settlement: { id: 1, ...posted } })
+ }),
+ )
+ const { default: userEvent } = await import('@testing-library/user-event')
+ const user = userEvent.setup()
+ render()
+
+ await user.click(await screen.findByRole('button', { name: 'Add payment' }))
+ await user.type(await screen.findByPlaceholderText('0.00'), '25')
+ // The footer submit is the second "Add payment" control once the modal is open.
+ const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
+ const submit = addButtons[addButtons.length - 1]
+ await user.click(submit)
+ await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
+ })
+
+ it('hides payment rows while a text search is active', async () => {
+ const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
+ server.use(
+ http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
+ http.get('/api/trips/1/budget/settlement', () =>
+ HttpResponse.json({
+ balances: [],
+ flows: [],
+ settlements: [
+ { id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
+ ],
+ })
+ ),
+ )
+ const { default: userEvent } = await import('@testing-library/user-event')
+ const user = userEvent.setup()
+ render()
+
+ await screen.findByText('Payment')
+ await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
+ // Payment rows have no name, so a search hides them while the matching expense stays.
+ expect(screen.queryByText('Payment')).not.toBeInTheDocument()
+ expect(screen.getByText('Dinner')).toBeInTheDocument()
+ })
+
+ it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
+ let posted: Record | 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
+ return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
+ }),
+ )
+ const { default: userEvent } = await import('@testing-library/user-event')
+ const user = userEvent.setup()
+ render()
+
+ 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.getAllByRole('spinbutton') 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'))
+
+ 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.objectContaining({ user_id: 1, amount: 30 }),
+ expect.objectContaining({ user_id: 2, amount: 70 }),
+ ]))
+ })
+
+ it('marks an expense with no payer as Unfinished', async () => {
+ const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
+ server.use(
+ http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
+ http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
+ )
+ render()
+ await screen.findByText('Hotel')
+ expect(screen.getByText('Unfinished')).toBeInTheDocument()
+ })
+})
diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx
index 8663bedb..9541275a 100644
--- a/client/src/components/Budget/CostsPanel.tsx
+++ b/client/src/components/Budget/CostsPanel.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
-import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
+import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -39,6 +39,12 @@ interface SettlementData {
settlements: Settlement[]
}
+// One row in the unified Costs ledger — either an expense or a settle-up payment,
+// carrying the date used to group it by day.
+type LedgerEntry =
+ | { kind: 'expense'; date: string; e: BudgetItem }
+ | { kind: 'payment'; date: string; s: Settlement }
+
const round2 = (n: number) => Math.round(n * 100) / 100
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
@@ -62,9 +68,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [settlement, setSettlement] = useState(null)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
- const [histOpen, setHistOpen] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState(null)
+ const [editingSettlement, setEditingSettlement] = useState(null)
+ const [addingPayment, setAddingPayment] = useState(false)
const people = tripMembers
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
@@ -122,21 +129,37 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return list
}, [budgetItems, filter, search, me])
+ // Settlements ("payments") shown inline in the ledger. They have no name, so a
+ // text search hides them; they're excluded from the "owed" expense filter and,
+ // under "mine", only show transfers I'm part of.
+ const filteredSettlements = useMemo(() => {
+ if (search.trim()) return []
+ if (filter === 'owed') return []
+ let list = settlement?.settlements || []
+ if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
+ return list
+ }, [settlement, filter, search, me])
+
const dayGroups = useMemo(() => {
- const groups: { day: string; items: BudgetItem[] }[] = []
- const labelOf = (e: BudgetItem) => {
- if (!e.expense_date) return t('costs.noDate')
- try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
+ const entries: LedgerEntry[] = [
+ ...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
+ ...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
+ ]
+ const labelOf = (date: string) => {
+ if (!date) return t('costs.noDate')
+ try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
}
- const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
- for (const e of sorted) {
- const day = labelOf(e)
+ // Newest day first; within a day, expenses before payments (insertion order).
+ const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
+ const groups: { day: string; entries: LedgerEntry[] }[] = []
+ for (const en of sorted) {
+ const day = labelOf(en.date)
let g = groups.find(x => x.day === day)
- if (!g) { g = { day, items: [] }; groups.push(g) }
- g.items.push(e)
+ if (!g) { g = { day, entries: [] }; groups.push(g) }
+ g.entries.push(en)
}
return groups
- }, [filtered, locale, t])
+ }, [filtered, filteredSettlements, locale, t])
// ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => {
@@ -280,14 +303,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{search ? t('costs.noMatch') : t('costs.emptyText')}
) : dayGroups.map(g => {
- const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
+ const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return (
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
- {g.items.map(e => )}
+ {g.entries.map(en => en.kind === 'expense'
+ ?
+ : )}
)
@@ -300,11 +325,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.settleUp')} · {(settlement?.flows || []).length}
-
+ {canEdit && (
+
+ )}
@@ -330,9 +357,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
)}
- setHistOpen(false)} title={t('costs.settleHistory')} size="md">
-
-
+ {(editingSettlement || addingPayment) && (
+ { setEditingSettlement(null); setAddingPayment(false) }}
+ onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
+ )}