feat(budget): make a settle-up payment's date editable

The settle-up form only exposed from/to/amount and silently dated the
payment by created_at, unlike a regular expense which has an editable
date. Add a settled_at column (independent of created_at, same split as
expense_date) through the schema, migration, service, REST and MCP
surfaces, and the desktop + mobile forms; the ledger groups a payment by
settled_at, falling back to created_at for rows recorded before the
column existed.
This commit is contained in:
RemEx
2026-09-11 15:04:51 +00:00
committed by Maurice
parent 76e182f44e
commit bd46089450
16 changed files with 200 additions and 36 deletions
+2 -2
View File
@@ -1327,8 +1327,8 @@ export const budgetApi = {
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
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; currency?: string }) => 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; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => 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; currency?: string; settled_at?: string | null }) => 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),
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
describe('splitCents', () => {
it('splits evenly when it divides cleanly', () => {
@@ -188,6 +188,21 @@ describe('readUserNote', () => {
})
})
describe('settlementDate', () => {
it('prefers settled_at over created_at', () => {
expect(settlementDate({ settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-05')
})
it('falls back to the day it was recorded when settled_at is unset', () => {
expect(settlementDate({ created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
expect(settlementDate({ settled_at: null, created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
})
it('is empty when neither is set', () => {
expect(settlementDate({})).toBe('')
})
})
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
describe('calculateTicketShares', () => {
@@ -34,6 +34,17 @@ export function splitCents(amount: number, n: number): number[] {
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
/**
* The calendar day a settle-up payment counts on for grouping/filtering: its own
* `settled_at` if the user set one, else the day it was recorded (`created_at`).
* Mirrors `expense_date` falling back nowhere on a budget item — a settlement's
* `created_at` doubled as its date before `settled_at` existed, so this keeps
* every pre-existing row grouped exactly where it already was.
*/
export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string {
return (s.settled_at || s.created_at || '').slice(0, 10)
}
/** Sum the amounts of the selected payers. */
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
@@ -740,7 +740,7 @@ describe('CostsPanel — settlements in the ledger', () => {
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string }
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string; settled_at?: string | null }
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
@@ -1051,6 +1051,21 @@ describe('CostsPanel — filtering the ledger', () => {
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
})
it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => {
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
// must follow settled_at, the same way it already follows expense_date over
// an expense's own created_at.
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
pickOption('Sun, Jun 15')
expect(screen.getByText('Dinner')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
expect(screen.getByText('Payment')).toBeInTheDocument()
})
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })])
@@ -1252,6 +1267,47 @@ describe('CostsPanel — payment modal', () => {
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
delete window.__addToast
})
it('FE-W5COSTS-074: a new payment defaults its day to today, by the local calendar', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
let posted: Record<string, unknown> | null = null
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 9 } })
}))
mount([])
try {
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '10')
const submits = screen.getAllByRole('button', { name: 'Add payment' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.settled_at).toBe('2026-08-12')
} finally {
vi.useRealTimers()
}
})
it('FE-W5COSTS-075: editing a payment keeps its own settled day, not the day it was recorded', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 7 } })
}))
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
await user.click(await screen.findByTitle('Edit'))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.settled_at).toBe('2025-06-10')
})
})
describe('CostsPanel — expense modal', () => {
+13 -4
View File
@@ -18,7 +18,7 @@ import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { localToday } from '../Planner/today'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
@@ -41,6 +41,10 @@ interface Settlement {
// read as the display currency, which is what the server assumes for them too.
currency?: string | null
created_at?: string
// The day the transfer actually happened; editable, unlike created_at (when it
// was recorded). Null/absent on rows predating this field — settlementDate()
// falls back to created_at for those.
settled_at?: string | null
from_username?: string
to_username?: string
}
@@ -178,14 +182,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
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)
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
if (dayFilter) list = list.filter(s => settlementDate(s) === dayFilter)
return list
}, [settlement, filter, search, catFilter, dayFilter, me])
const dayGroups = useMemo(() => {
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 })),
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: settlementDate(s), s })),
]
const labelOf = (date: string) => {
if (!date) return t('costs.noDate')
@@ -1014,6 +1018,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
// "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals.
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
const [day, setDay] = useState(editing ? settlementDate(editing) : localToday())
const [saving, setSaving] = useState(false)
const amt = Number.parseFloat(amount) || 0
@@ -1023,7 +1028,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur }
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
@@ -1067,6 +1072,10 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
style={{ width: '100%' }} />
</div>
</div>
<div>
<label className={labelCls}>{t('costs.day')}</label>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
</div>
</Modal>
)
@@ -15,6 +15,8 @@ import MCostSheet from '../sheets/MCostSheet'
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
import { readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import { localToday } from '../../../../components/Planner/today'
import MConfirmSheet from '../../settings/MConfirmSheet'
import MSheet from '../../../components/MSheet'
import MChip from '../../../components/MChip'
@@ -737,7 +739,9 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: {
* No pixel spec exists for this form (the demo only toasts "Demo: add payment",
* 03-trip-tabs.md §3.8) and no mobile/exported-desktop sheet covers it, so this
* is a small local sheet built from the trip form-sheet chrome, kept to the
* fields the settle-up card itself needs: from, to, amount in the display currency.
* fields the settle-up card itself needs: from, to, amount in the display
* currency, and the day it happened (editable like an expense's, unlike the
* legacy created_at-only date — see `settlementDate` in CostsPanel.helpers.ts).
*/
function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, onSaved }: {
open: boolean
@@ -753,6 +757,7 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on
const [fromId, setFromId] = useState(me)
const [toId, setToId] = useState(() => people.find(p => p.id !== me)?.id ?? me)
const [amount, setAmount] = useState('')
const [day, setDay] = useState(localToday())
const [saving, setSaving] = useState(false)
useEffect(() => {
@@ -760,6 +765,7 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on
setFromId(me)
setToId(people.find(p => p.id !== me)?.id ?? me)
setAmount('')
setDay(localToday())
setSaving(false)
}, [open, me, people])
@@ -770,7 +776,7 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on
if (!valid || saving) return
setSaving(true)
try {
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount: amt, currency: base })
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount: amt, currency: base, settled_at: day })
onSaved()
} catch {
toast.error(t('common.unknownError'))
@@ -808,6 +814,9 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on
<input type="text" inputMode="decimal" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0.00" className={FIELD_CLS} />
<span className="flex-none font-geist text-[0.75rem] font-bold text-m-faint">{base}</span>
</div>
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.day')}</Eyebrow>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
<FormSheetFooter onCancel={onClose} cancelLabel={t('common.cancel')} onSubmit={save} submitLabel={t('costs.addPayment')} submitDisabled={!valid || saving} />
</MSheet>
@@ -1,5 +1,5 @@
import type { CostCategory } from '@trek/shared'
import { readUserNote, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
import { currencyDecimals } from '../../../../utils/formatters'
import type { BudgetItem } from '../../../../types'
@@ -87,6 +87,9 @@ export interface CostsSettlement {
// Legacy rows predate this column (null) and are read as the display currency.
currency?: string | null
created_at?: string
// The day the transfer actually happened; editable, unlike created_at (when it
// was recorded). Null/absent on rows predating this field.
settled_at?: string | null
}
export interface CostsSettlementResponse {
@@ -183,7 +186,7 @@ export function filterSettlements(settlements: CostsSettlement[], f: CostsFilter
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)
if (f.dayKey) list = list.filter(s => settlementDate(s) === f.dayKey)
return list
}
@@ -199,14 +202,14 @@ export interface CostsLedgerDayGroup {
/**
* 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
* {@link filterSettlements}) as their own ledger entries, keyed by
* {@link settlementDate} — 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 })),
...settlements.map(settlement => ({ kind: 'payment' as const, date: settlementDate(settlement), settlement })),
]
const byDate = new Map<string, CostsLedgerEntry[]>()
for (const en of entries) {