diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx
index 6772ca0ef..a87cdec3e 100644
--- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx
+++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
- AlertCircle, ArrowDown, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
+ AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
Layers, Pencil, Plus, StickyNote, Trash2,
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
@@ -22,8 +22,8 @@ import { CountPill, TabScroller } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import {
baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, currencyOf,
- dayFilterKeys, filterBudgetItems, groupByDay, isUnfinished, memberShareOf, tint,
- type CostsCtx, type CostsSegment, type CostsSettlementResponse,
+ dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, tint,
+ type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse,
} from './costsModel'
import type { BudgetItem, TripMember } from '../../../../types'
@@ -85,7 +85,11 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
() => filterBudgetItems(budgetItems, { search, segment, categoryKey: catFilter, dayKey: dayFilter }, ctx),
[budgetItems, search, segment, catFilter, dayFilter, ctx],
)
- const groups = useMemo(() => groupByDay(filtered), [filtered])
+ const filteredSettlements = useMemo(
+ () => filterSettlements(settlement?.settlements || [], { search, segment, categoryKey: catFilter, dayKey: dayFilter }, me),
+ [settlement, search, segment, catFilter, dayFilter, me],
+ )
+ const groups = useMemo(() => groupLedgerByDay(filtered, filteredSettlements), [filtered, filteredSettlements])
const catBreakdown = useMemo(() => categoryBreakdown(budgetItems, ctx), [budgetItems, ctx])
const catKeys = useMemo(() => categoryFilterKeys(budgetItems), [budgetItems])
const dayKeys = useMemo(() => dayFilterKeys(budgetItems), [budgetItems])
@@ -425,9 +429,9 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
)}
- {/* Expense groups (spec §3.7) */}
+ {/* Expense groups (spec §3.7) — expenses and recorded settle-up payments, day-merged */}
{groups.map(g => {
- const groupTotal = g.items.reduce((a, e) => a + baseTotal(e, ctx), 0)
+ const groupTotal = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.item, ctx) : a, 0)
return (
@@ -436,21 +440,31 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
{t('costs.spent', { amount: formatMoney(groupTotal, base, locale) })}
- {g.items.map(item => (
+ {g.entries.map(en => en.kind === 'expense' ? (
{
- setEditingExpense(item)
+ setEditingExpense(en.item)
setExpenseModalOpen(true)
}}
- onDelete={() => setConfirmDelete(item)}
- onTogglePaid={(userId, paid) => handleTogglePaid(item.id, userId, paid)}
+ onDelete={() => setConfirmDelete(en.item)}
+ onTogglePaid={(userId, paid) => handleTogglePaid(en.item.id, userId, paid)}
+ />
+ ) : (
+
))}
@@ -630,6 +644,42 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
)
}
+/**
+ * A recorded settle-up payment mixed into the day-grouped ledger (spec 03
+ * §3.7 desktop parity — see `CostsPanel.tsx`'s `SettlementRow`). Read-only:
+ * editing/undoing a payment stays a desktop-only action for now, this row
+ * only closes the "it vanishes into thin air" gap on mobile.
+ */
+function PaymentRow({ settlement, ctx, base, locale, t, personName }: {
+ settlement: CostsSettlement
+ ctx: CostsCtx
+ base: string
+ locale: string
+ t: TFn
+ personName: (id: number) => string
+}) {
+ const cur = (settlement.currency || base).toUpperCase()
+ const amount = ctx.convert(settlement.amount, cur)
+ return (
+
+
+
+
+
+
+
+
{t('costs.payment')}
+
{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}
+
+
+ {formatMoney(amount, base, locale)}
+
+
+
+
+ )
+}
+
/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */
function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: {
name: string
diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts
index 181bf8d01..085659659 100644
--- a/client/src/mobile/screens/trip/tabs/costsModel.ts
+++ b/client/src/mobile/screens/trip/tabs/costsModel.ts
@@ -74,9 +74,21 @@ export interface CostsBalance {
balance: number
}
+/** A recorded settle-up transfer ("Ajouter un paiement") — history, not a suggestion like {@link CostsSettlementFlow}. */
+export interface CostsSettlement {
+ id: number
+ from_user_id: number
+ to_user_id: number
+ amount: number
+ // Legacy rows predate this column (null) and are read as the display currency.
+ currency?: string | null
+ created_at?: string
+}
+
export interface CostsSettlementResponse {
balances: CostsBalance[]
flows: CostsSettlementFlow[]
+ settlements: CostsSettlement[]
}
// ── hero / tile totals (spec §3.1-§3.3) ────────────────────────────────────
@@ -156,6 +168,57 @@ export function groupByDay(items: BudgetItem[]): CostsDayGroup[] {
return keys.map(dateKey => ({ dateKey, items: byDate.get(dateKey) as BudgetItem[] }))
}
+/**
+ * Settlements ("payments") shown inline in the ledger, mirroring the desktop
+ * `CostsPanel.tsx`'s `filteredSettlements`: they have no name/category, so a
+ * text or category filter hides them; "owed" excludes them; "mine" keeps only
+ * transfers the current user is part of.
+ */
+export function filterSettlements(settlements: CostsSettlement[], f: CostsFilterState, me: number): CostsSettlement[] {
+ if (f.search.trim() || f.categoryKey) return []
+ if (f.segment === 'owed') return []
+ let list = settlements.slice()
+ if (f.segment === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
+ if (f.dayKey) list = list.filter(s => (s.created_at || '').slice(0, 10) === f.dayKey)
+ return list
+}
+
+export type CostsLedgerEntry =
+ | { kind: 'expense'; date: string; item: BudgetItem }
+ | { kind: 'payment'; date: string; settlement: CostsSettlement }
+
+export interface CostsLedgerDayGroup {
+ /** '' = no date (spec's "NO DATE" group) */
+ dateKey: string
+ entries: CostsLedgerEntry[]
+}
+
+/**
+ * Like {@link groupByDay}, but also folds in settlement payments (see
+ * {@link filterSettlements}) as their own ledger entries, keyed by the day
+ * they were recorded — the mobile counterpart to desktop's unified
+ * `LedgerEntry` grouping, so a payment shows up even on a day with no expense.
+ */
+export function groupLedgerByDay(items: BudgetItem[], settlements: CostsSettlement[]): CostsLedgerDayGroup[] {
+ const entries: CostsLedgerEntry[] = [
+ ...items.map(item => ({ kind: 'expense' as const, date: item.expense_date || '', item })),
+ ...settlements.map(settlement => ({ kind: 'payment' as const, date: (settlement.created_at || '').slice(0, 10), settlement })),
+ ]
+ const byDate = new Map()
+ for (const en of entries) {
+ const bucket = byDate.get(en.date)
+ if (bucket) bucket.push(en)
+ else byDate.set(en.date, [en])
+ }
+ const keys = Array.from(byDate.keys()).sort((a, b) => {
+ if (a === b) return 0
+ if (a === '') return 1
+ if (b === '') return -1
+ return b.localeCompare(a)
+ })
+ return keys.map(dateKey => ({ dateKey, entries: byDate.get(dateKey) as CostsLedgerEntry[] }))
+}
+
/** Categories present among `items`, canonical order — the dropdown only lists categories in use (spec §3.6). */
export function categoryFilterKeys(items: BudgetItem[]): CostCategory[] {
const present = new Set(items.map(e => catMeta(e.category).key))
diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx
index 9a1f3029f..583d3b92d 100644
--- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx
+++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx
@@ -557,4 +557,43 @@ describe('MCostsTab', () => {
expect(settlementBases).toEqual(['GBP'])
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('£92.50')
})
+
+ it('FE-MOB-COSTT-038: renders a recorded settlement payment inline with the day it was made on', async () => {
+ const payment = { id: 501, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', created_at: '2026-04-30T09:00:00Z' }
+ serveSettlement({ ...SETTLEMENT, settlements: [payment] })
+ await renderTab()
+ const group = up(screen.getByText('Thu, Apr 30'), 2)
+ expect(within(group).getByText('costs.payment')).toBeInTheDocument()
+ expect(within(group).getByText('costs.you → Ada')).toBeInTheDocument()
+ expect(within(group).getByText('$25.00')).toBeInTheDocument()
+ // The per-day "spent" total only counts expenses, not payments.
+ expect(within(group).getByText('costs.spent:$60.00')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-COSTT-039: a payment on a day with no expense still creates its own ledger group', async () => {
+ const payment = { id: 502, from_user_id: 3, to_user_id: 1, amount: 15, currency: 'USD', created_at: '2026-06-15T09:00:00Z' }
+ serveSettlement({ ...SETTLEMENT, settlements: [payment] })
+ await renderTab()
+ expect(screen.getByText('costs.payment')).toBeInTheDocument()
+ const row = up(screen.getByText('Bob → costs.you'), 2)
+ expect(within(row).getByText('$15.00')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-COSTT-040: the expense filters apply to payments the same way they do on desktop', async () => {
+ const mine = { id: 503, from_user_id: 1, to_user_id: 2, amount: 8, currency: 'USD', created_at: '2026-05-02T09:00:00Z' }
+ const others = { id: 504, from_user_id: 2, to_user_id: 3, amount: 9, currency: 'USD', created_at: '2026-05-02T09:00:00Z' }
+ serveSettlement({ ...SETTLEMENT, settlements: [mine, others] })
+ await renderTab()
+ expect(screen.getAllByText('costs.payment')).toHaveLength(2)
+
+ fireEvent.click(screen.getByRole('button', { name: 'costs.filter.owed' }))
+ expect(screen.queryByText('costs.payment')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'costs.filter.mine' }))
+ expect(screen.getAllByText('costs.payment')).toHaveLength(1)
+
+ fireEvent.click(screen.getByRole('button', { name: 'costs.filter.all' }))
+ fireEvent.change(screen.getByLabelText('costs.searchPlaceholder'), { target: { value: 'ramen' } })
+ expect(screen.queryByText('costs.payment')).not.toBeInTheDocument()
+ })
})
diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts
index dd399a306..74cb3e427 100644
--- a/client/tests/unit/mobile/trip/costsModel.test.ts
+++ b/client/tests/unit/mobile/trip/costsModel.test.ts
@@ -8,7 +8,9 @@ import {
currencyOf,
dayFilterKeys,
filterBudgetItems,
+ filterSettlements,
groupByDay,
+ groupLedgerByDay,
isUnfinished,
memberShareOf,
myPaidOf,
@@ -16,6 +18,7 @@ import {
tint,
type CostsCtx,
type CostsFilterState,
+ type CostsSettlement,
type CostsSettlementFlow,
} from '../../../../src/mobile/screens/trip/tabs/costsModel';
import { buildBudgetItem } from '../../../helpers/factories';
@@ -43,6 +46,10 @@ function payer(user_id: number, amount: number): Payer {
return { user_id, amount, username: `u${user_id}` };
}
+function settlement(overrides: Partial = {}): CostsSettlement {
+ return { id: 1, from_user_id: 1, to_user_id: 2, amount: 10, currency: null, created_at: '2026-07-01T10:00:00Z', ...overrides };
+}
+
function expense(overrides: Partial = {}): BudgetItem {
return buildBudgetItem({ category: 'other', ...overrides });
}
@@ -264,6 +271,64 @@ describe('costsModel — list filters and grouping', () => {
];
expect(dayFilterKeys(items)).toEqual(['2026-07-01', '2026-07-03']);
});
+
+ it('FE-MOB-CMOD-029: filterSettlements hides settlements when a search or category filter is active — they have neither', () => {
+ const list = [settlement()];
+ expect(filterSettlements(list, filters({ search: 'x' }), 1)).toEqual([]);
+ expect(filterSettlements(list, filters({ categoryKey: 'food' }), 1)).toEqual([]);
+ });
+
+ it('FE-MOB-CMOD-030: filterSettlements excludes them under "owed" and keeps only mine under "mine"', () => {
+ const mine = settlement({ id: 1, from_user_id: 1, to_user_id: 2 });
+ const others = settlement({ id: 2, from_user_id: 2, to_user_id: 3 });
+ const list = [mine, others];
+ expect(filterSettlements(list, filters({ segment: 'owed' }), 1)).toEqual([]);
+ expect(filterSettlements(list, filters({ segment: 'mine' }), 1).map(s => s.id)).toEqual([1]);
+ expect(filterSettlements(list, filters({ segment: 'all' }), 1).map(s => s.id)).toEqual([1, 2]);
+ });
+
+ it('FE-MOB-CMOD-031: filterSettlements narrows by the recorded day', () => {
+ const a = settlement({ id: 1, created_at: '2026-07-01T09:00:00Z' });
+ const b = settlement({ id: 2, created_at: '2026-07-02T09:00:00Z' });
+ expect(filterSettlements([a, b], filters({ dayKey: '2026-07-02' }), 1).map(s => s.id)).toEqual([2]);
+ });
+});
+
+describe('costsModel — ledger grouping (expenses + settlement payments)', () => {
+ it('FE-MOB-CMOD-032: merges expenses and payments into shared day groups, newest first, no-date last', () => {
+ const e = expense({ id: 50, expense_date: '2026-07-02' });
+ const undatedExpense = expense({ id: 51, expense_date: null });
+ const s = settlement({ id: 1, created_at: '2026-07-03T12:00:00Z' });
+
+ const groups = groupLedgerByDay([e, undatedExpense], [s]);
+
+ expect(groups.map(g => g.dateKey)).toEqual(['2026-07-03', '2026-07-02', '']);
+ expect(groups[0].entries).toEqual([{ kind: 'payment', date: '2026-07-03', settlement: s }]);
+ expect(groups[1].entries).toEqual([{ kind: 'expense', date: '2026-07-02', item: e }]);
+ expect(groups[2].entries).toEqual([{ kind: 'expense', date: '', item: undatedExpense }]);
+ });
+
+ it('FE-MOB-CMOD-033: a payment recorded the same day as an expense lands in that same group', () => {
+ const e = expense({ id: 52, expense_date: '2026-07-05' });
+ const s = settlement({ id: 2, created_at: '2026-07-05T08:00:00Z' });
+
+ expect(groupLedgerByDay([e], [s])).toEqual([{
+ dateKey: '2026-07-05',
+ entries: [
+ { kind: 'expense', date: '2026-07-05', item: e },
+ { kind: 'payment', date: '2026-07-05', settlement: s },
+ ],
+ }]);
+ });
+
+ it('FE-MOB-CMOD-034: a payment on a day with no expense still creates its own day group', () => {
+ const s = settlement({ id: 3, created_at: '2026-07-09T08:00:00Z' });
+ expect(groupLedgerByDay([], [s])).toEqual([{ dateKey: '2026-07-09', entries: [{ kind: 'payment', date: '2026-07-09', settlement: s }] }]);
+ });
+
+ it('FE-MOB-CMOD-035: empty inputs produce no groups', () => {
+ expect(groupLedgerByDay([], [])).toEqual([]);
+ });
});
describe('costsModel — breakdown and presentation', () => {