mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user