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