From bd460894500e3ee56dedfbcf665a69b03287dd55 Mon Sep 17 00:00:00 2001 From: RemEx <74838147+Rem7474@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:04:51 +0000 Subject: [PATCH] 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. --- client/src/api/client.ts | 4 +- .../Budget/CostsPanel.helpers.test.ts | 17 +++++- .../components/Budget/CostsPanel.helpers.ts | 11 ++++ .../src/components/Budget/CostsPanel.test.tsx | 58 ++++++++++++++++++- client/src/components/Budget/CostsPanel.tsx | 17 ++++-- .../mobile/screens/trip/tabs/MCostsTab.tsx | 13 ++++- .../mobile/screens/trip/tabs/costsModel.ts | 13 +++-- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 3 +- .../tests/unit/mobile/trip/costsModel.test.ts | 11 ++++ server/src/db/migrations.ts | 14 +++++ server/src/nest/budget/budget.controller.ts | 3 +- server/src/nest/budget/budget.mcp.ts | 10 ++-- server/src/nest/budget/budget.service.ts | 21 ++++--- .../tests/unit/nest/budget.controller.test.ts | 12 ++-- .../tests/unit/nest/budget.service.db.test.ts | 21 +++++++ shared/src/budget/budget.schema.ts | 8 +++ 16 files changed, 200 insertions(+), 36 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 532beeaef..8313aaee9 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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), diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index 195f6b1b8..b9c14a703 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -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', () => { diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index ef2877b30..8ea99177f 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -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, ids: Set): number { return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0) diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index f85fd752a..0a71e0e30 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -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 | null = null + server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => { + posted = await request.json() as Record + 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 | null = null + server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => { + put = await request.json() as Record + 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', () => { diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index c4d015fdf..e5513202c 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -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(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '') const [cur, setCur] = useState((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%' }} /> +
+ + +
) diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 641f5c93c..96d4be410 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -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 setAmount(e.target.value)} placeholder="0.00" className={FIELD_CLS} /> {base} + + {t('costs.day')} + diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts index 5aa829d9c..11dc955a9 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -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() for (const en of entries) { diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 583d3b92d..228caefa8 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -3,6 +3,7 @@ import { http, HttpResponse } from 'msw' import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { budgetApi } from '../../../../src/api/client' +import { localToday } from '../../../../src/components/Planner/today' import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { useAuthStore } from '../../../../src/store/authStore' import { useSettingsStore } from '../../../../src/store/settingsStore' @@ -463,7 +464,7 @@ describe('MCostsTab', () => { fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } }) expect(submit).toBeEnabled() fireEvent.click(submit) - expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD' }) + expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD', settled_at: localToday() }) await waitFor(() => expect(settlementBases).toHaveLength(2)) }) diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts index 855f119b6..9a239cd1d 100644 --- a/client/tests/unit/mobile/trip/costsModel.test.ts +++ b/client/tests/unit/mobile/trip/costsModel.test.ts @@ -298,6 +298,12 @@ describe('costsModel — list filters and grouping', () => { 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]); }); + + it('FE-MOB-CMOD-036: filterSettlements narrows by settled_at when set, over the day it was recorded', () => { + const a = settlement({ id: 1, settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' }); + expect(filterSettlements([a], filters({ dayKey: '2026-07-05' }), 1).map(s => s.id)).toEqual([1]); + expect(filterSettlements([a], filters({ dayKey: '2026-07-01' }), 1)).toEqual([]); + }); }); describe('costsModel — ledger grouping (expenses + settlement payments)', () => { @@ -332,6 +338,11 @@ describe('costsModel — ledger grouping (expenses + settlement payments)', () = expect(groupLedgerByDay([], [s])).toEqual([{ dateKey: '2026-07-09', entries: [{ kind: 'payment', date: '2026-07-09', settlement: s }] }]); }); + it('FE-MOB-CMOD-037: a payment groups by its own settled_at, not the day it was recorded', () => { + const s = settlement({ id: 4, settled_at: '2026-07-11', created_at: '2026-07-09T08:00:00Z' }); + expect(groupLedgerByDay([], [s])).toEqual([{ dateKey: '2026-07-11', entries: [{ kind: 'payment', date: '2026-07-11', settlement: s }] }]); + }); + it('FE-MOB-CMOD-035: empty inputs produce no groups', () => { expect(groupLedgerByDay([], [])).toEqual([]); }); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index e162c82c8..5736587bd 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4576,6 +4576,20 @@ function runMigrations(db: Database.Database): void { const inherit = db.prepare('INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL'); for (const key of ROADTRIP_PREFERENCE_KEYS) inherit.run(key); }, + + // A settle-up payment's date was silently `created_at` (when it was recorded), + // not editable like a regular expense's `expense_date`. Add the same split: + // settled_at is the calendar day the transfer actually happened, independent + // of when someone got around to logging it. NULL on legacy rows and rows + // whose caller didn't set it; the read side falls back to created_at's date. + // + // Appended LAST: the array is index-addressed against schema_version. + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('budget_settlements')").all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'settled_at')) { + db.exec('ALTER TABLE budget_settlements ADD COLUMN settled_at TEXT'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index 319c3ded6..e575bd441 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -91,7 +91,7 @@ export class BudgetController { ) { const settlement = await this.budget.createSettlement( tripId, - { from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency }, + { from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency, settled_at: body.settled_at }, user.id, ); // A party who is not on this trip gets the same answer as a settlement that @@ -117,6 +117,7 @@ export class BudgetController { to_user_id: body.to_user_id, amount: body.amount, currency: body.currency, + settled_at: body.settled_at, }); if (!settlement) { throw new HttpException({ error: 'Settlement not found' }, 404); diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 630cc3e04..553b92d34 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -433,13 +433,14 @@ export class BudgetMcp { to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'), amount: z.number().positive().describe('Amount paid, in `currency`'), currency: z.string().max(10).nullable().optional().describe("ISO currency code the payment was made in (e.g. \"USD\"); defaults to the trip currency. Its FX rate is frozen now, so the transfer keeps cancelling its expense when live rates drift."), + settled_at: z.string().max(40).nullable().optional().describe('Date the payment actually happened, YYYY-MM-DD; defaults to today when omitted.'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: budgetAddonOn, access: { group: 'budget', mode: 'write' }, }) async createSettlement( - { tripId, from_user_id, to_user_id, amount, currency }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null }, + { tripId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, ctx: McpContext, ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); @@ -447,7 +448,7 @@ export class BudgetMcp { if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); // Freeze-then-write composite, same as the REST path: the rate for the display // currency is frozen at entry time (#1445). - const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency }, ctx.userId); + const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency, settled_at }, ctx.userId); if (!settlement) return errorResult('Settlement not found.'); this.guards.safeBroadcast(tripId, 'budget:settlement-created', { settlement }); return ok({ settlement }); @@ -463,13 +464,14 @@ export class BudgetMcp { to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'), amount: z.number().positive().describe('Amount paid, in `currency`'), currency: z.string().max(10).nullable().optional().describe('ISO currency code the payment was made in (e.g. "USD"); null puts it back in the trip currency. Omit to leave it as recorded, which also keeps the rate frozen at settle time.'), + settled_at: z.string().max(40).nullable().optional().describe('Date the payment actually happened, YYYY-MM-DD. Omit to leave it as recorded.'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: budgetAddonOn, access: { group: 'budget', mode: 'write' }, }) async updateSettlement( - { tripId, settlementId, from_user_id, to_user_id, amount, currency }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null }, + { tripId, settlementId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, ctx: McpContext, ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); @@ -477,7 +479,7 @@ export class BudgetMcp { if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); // Freeze-then-write composite, same as the REST path: an edit that leaves the // currency alone keeps the rate frozen at settle time. - const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency }); + const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency, settled_at }); if (!settlement) return errorResult('Settlement not found.'); this.guards.safeBroadcast(tripId, 'budget:settlement-updated', { settlement }); return ok({ settlement }); diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 16250e6f9..4bdeb3858 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -12,7 +12,7 @@ type Trip = TripAccess; type SettlementRow = { id: number; trip_id: string; from_user_id: number; to_user_id: number; amount: number; currency: string | null; exchange_rate: number | null; - created_at: string; created_by_user_id: number | null; + created_at: string; settled_at: string | null; created_by_user_id: number | null; from_username: string; from_avatar: string | null; to_username: string; to_avatar: string | null; }; @@ -1052,7 +1052,7 @@ export class BudgetService { // Settlement usernames use COALESCE(display_name, username) like every item // query (the legacy raw fu.username was the odd one out). private static readonly SETTLEMENT_SELECT = ` - SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.created_by_user_id, + SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.settled_at, s.created_by_user_id, COALESCE(fu.display_name, fu.username) AS from_username, fu.avatar AS from_avatar, COALESCE(tu.display_name, tu.username) AS to_username, tu.avatar AS to_avatar FROM budget_settlements s @@ -1065,7 +1065,7 @@ export class BudgetService { id: r.id, trip_id: r.trip_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.amount, currency: r.currency ?? null, exchange_rate: r.exchange_rate ?? 1, - created_at: r.created_at, created_by_user_id: r.created_by_user_id, + created_at: r.created_at, settled_at: r.settled_at ?? null, created_by_user_id: r.created_by_user_id, from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }), to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }), }; @@ -1092,14 +1092,15 @@ export class BudgetService { /** Raw settlement insert (no FX freeze) — the REST path wraps it in createSettlement. */ insertSettlement( tripId: string | number, - data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number }, + data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null }, createdByUserId?: number, ) { const result = this.db.run( - 'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, settled_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, data.currency ? data.currency.toUpperCase() : null, data.exchange_rate != null ? data.exchange_rate : 1, + data.settled_at || null, createdByUserId ?? null, ); return this.getSettlement(Number(result.lastInsertRowid), tripId); @@ -1109,7 +1110,7 @@ export class BudgetService { applySettlementUpdate( id: string | number, tripId: string | number, - data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number }, + data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null }, ) { const row = this.db.get('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?', id, tripId); if (!row) return null; @@ -1117,12 +1118,14 @@ export class BudgetService { UPDATE budget_settlements SET from_user_id = ?, to_user_id = ?, amount = ?, currency = CASE WHEN ? THEN ? ELSE currency END, - exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END + exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END, + settled_at = CASE WHEN ? THEN ? ELSE settled_at END WHERE id = ? `, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, data.currency !== undefined ? 1 : 0, data.currency ? data.currency.toUpperCase() : null, data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1, + data.settled_at !== undefined ? 1 : 0, data.settled_at || null, id, ); return this.getSettlement(id, tripId); @@ -1184,7 +1187,7 @@ export class BudgetService { return roster.has(data.from_user_id) && roster.has(data.to_user_id); } - async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }, userId: number) { + async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, userId: number) { if (!this.settlementPartiesOnTrip(tripId, data)) return null; // Freeze the FX rate for the display currency the amount was entered in so the // transfer keeps cancelling its expense when live rates drift (#1445). @@ -1192,7 +1195,7 @@ export class BudgetService { return this.insertSettlement(tripId, data, userId); } - async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }) { + async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }) { // Pass the settlement's stored currency so an edit that doesn't change it keeps // the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a // settled position on an unrelated edit. diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index 4dca976b2..e33f96685 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -82,13 +82,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou // produced by the global ZodValidationPipe (budget.dto.ts) before the handler // runs — covered by the integration suite, not constructible here. - it('POST /settlements creates and broadcasts (amount 0 is allowed), forwarding the display currency', async () => { + it('POST /settlements creates and broadcasts (amount 0 is allowed), forwarding the display currency and the settled day', async () => { const createSettlement = vi.fn().mockResolvedValue({ id: 3, amount: 0 }); const broadcast = vi.fn(); const svc = makeService({ createSettlement, broadcast } as Partial); - const res = await new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD' }, 'sock'); + const res = await new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD', settled_at: '2026-01-05' }, 'sock'); expect(res).toEqual({ settlement: { id: 3, amount: 0 } }); - expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD' }, user.id); + expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD', settled_at: '2026-01-05' }, user.id); expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock'); }); @@ -114,13 +114,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou }); }); - it('PUT /settlements/:id updates and broadcasts, forwarding the display currency', async () => { + it('PUT /settlements/:id updates and broadcasts, forwarding the display currency and the settled day', async () => { const updateSettlement = vi.fn().mockResolvedValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 }); const broadcast = vi.fn(); const svc = makeService({ updateSettlement, broadcast } as Partial); - const res = await new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD' }, 'sock'); + const res = await new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD', settled_at: '2026-01-06' }, 'sock'); expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }); - expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD' }); + expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD', settled_at: '2026-01-06' }); expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock'); }); }); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 34240341a..6cf9b7ae9 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -769,6 +769,27 @@ describe('post-fold quirk fixes', () => { expect(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed'); }); + it('BUDGET-SVC-DB-042: a settle-up payment carries its own settled_at, independent of created_at', () => { + const { user: alice } = createUser(testDb, { username: 'alice' }); + const { user: bob } = createUser(testDb, { username: 'bob' }); + const trip = createTrip(testDb, alice.id); + + const noDate = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id); + expect(noDate!.settled_at).toBeNull(); + + const dated = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-05' }, alice.id); + expect(dated!.settled_at).toBe('2026-01-05'); + expect(budget.getSettlement(dated!.id, trip.id)!.settled_at).toBe('2026-01-05'); + + const moved = budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-09' }); + expect(moved!.settled_at).toBe('2026-01-09'); + + // An update that omits settled_at (undefined) leaves the stored day alone, + // the same CASE WHEN pattern currency/exchange_rate already follow. + const untouched = budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 25 }); + expect(untouched!.settled_at).toBe('2026-01-09'); + }); + // ── Notes vs. itemized receipts (#1658) ──────────────────────────────────── it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', () => { diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index 4cb9febe2..5d115d39d 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -221,6 +221,11 @@ export const budgetSettlementSchema = z.object({ currency: z.string().nullable().optional(), exchange_rate: z.number().optional(), created_at: z.string().optional(), + // The calendar day the transfer actually happened (YYYY-MM-DD), independent of + // `created_at` (when it was recorded) — same split as budget_items' expense_date + // vs. created_at. Null/absent on rows recorded before this column existed; the + // ledger falls back to `created_at`'s date for those. + settled_at: z.string().nullable().optional(), created_by_user_id: z.number().nullable().optional(), from_username: z.string().optional(), from_avatar_url: z.string().nullable().optional(), @@ -235,6 +240,8 @@ export const budgetCreateSettlementRequestSchema = z.object({ amount: z.number(), // The display currency the amount was entered in; the server freezes its FX rate. currency: z.string().nullable().optional(), + // The day the transfer happened; defaults to today (server-side) when omitted. + settled_at: z.string().nullable().optional(), }); export type BudgetCreateSettlementRequest = z.infer; @@ -244,6 +251,7 @@ export const budgetUpdateSettlementRequestSchema = z.object({ to_user_id: z.number(), amount: z.number(), currency: z.string().nullable().optional(), + settled_at: z.string().nullable().optional(), }); export type BudgetUpdateSettlementRequest = z.infer;