fix(costs): show recorded settlement payments in the mobile Costs tab

Closes #2151

The desktop Costs view mixes recorded settle-up payments into the day-grouped
expense ledger, but the mobile screen only ever fetched balances/flows and
silently dropped the settlements array the server already returns — a
recorded payment cleared the balance but then left no trace anywhere on
mobile.
This commit is contained in:
RemEx
2026-08-30 09:09:58 +00:00
committed by Maurice
parent 537c450653
commit 595a76321f
4 changed files with 229 additions and 12 deletions
@@ -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) {
</div>
)}
{/* 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 (
<div key={g.dateKey || 'no-date'}>
<div className="mt-[14px] flex items-baseline gap-2 px-[2px]">
@@ -436,21 +440,31 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
{t('costs.spent', { amount: formatMoney(groupTotal, base, locale) })}
</span>
</div>
{g.items.map(item => (
{g.entries.map(en => en.kind === 'expense' ? (
<ExpenseRow
key={item.id}
item={item}
key={'e' + en.item.id}
item={en.item}
ctx={ctx}
base={base}
locale={locale}
t={t}
canEdit={canEdit}
onEdit={() => {
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)}
/>
) : (
<PaymentRow
key={'s' + en.settlement.id}
settlement={en.settlement}
ctx={ctx}
base={base}
locale={locale}
t={t}
personName={personName}
/>
))}
</div>
@@ -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 (
<div className="mt-2 flex items-center gap-[6px]">
<div className="relative min-w-0 flex-1 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-card px-3 py-[12px]">
<div className="flex items-center gap-[10px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[12px]" style={{ background: 'rgba(47,163,122,.12)', color: STATUS_COLOR.confirmed }}>
<ArrowLeftRight size={17} strokeWidth={2.2} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{t('costs.payment')}</div>
<div className="truncate font-geist text-[0.65625rem] text-m-faint">{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}</div>
</div>
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[11px] py-1 font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">
{formatMoney(amount, base, locale)}
</span>
</div>
</div>
</div>
)
}
/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */
function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: {
name: string
@@ -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<string, CostsLedgerEntry[]>()
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))
@@ -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()
})
})
@@ -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> = {}): 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> = {}): 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', () => {