From 595a76321f222c577d63c51d9004bb8c84344e29 Mon Sep 17 00:00:00 2001 From: RemEx <74838147+Rem7474@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:09:58 +0000 Subject: [PATCH] fix(costs): show recorded settlement payments in the mobile Costs tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../mobile/screens/trip/tabs/MCostsTab.tsx | 74 ++++++++++++++++--- .../mobile/screens/trip/tabs/costsModel.ts | 63 ++++++++++++++++ .../tests/unit/mobile/trip/MCostsTab.test.tsx | 39 ++++++++++ .../tests/unit/mobile/trip/costsModel.test.ts | 65 ++++++++++++++++ 4 files changed, 229 insertions(+), 12 deletions(-) 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', () => {