From 9064dbb2f03d14388091326e29e479488f52187d Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 15:04:22 +0200 Subject: [PATCH] fix(costs): an expense made from a booking is dated on the booking day Creating the expense from a booking opened the editor on today, and the cost a booking import writes by itself had no date at all, so it sat under "No date". Both now take the booking's own date, else a hotel's check-in day, else the day the booking sits on. The server sets it for every booking write that creates a cost, so REST, MCP, plugin RPC and the import agree. A start time typed on a booking with no day picked no longer borrows today's date on desktop. Today was mostly outside the trip, the server then moved the booking onto day 1 while the list kept today. Desktop now does what the phone already did and keeps the time without a date. Refs #2604 --- .../components/Planner/ReservationModal.tsx | 7 ++-- .../src/components/Planner/TransportModal.tsx | 2 +- .../Planner/bookingFormModel.test.ts | 29 +++++++++++---- .../components/Planner/bookingFormModel.ts | 36 +++++++++++++++--- .../Planner/reservationFormModel.test.ts | 10 ++++- .../Planner/reservationFormModel.ts | 12 ++++++ .../screens/trip/sheets/MReservationSheet.tsx | 6 +-- .../trip/sheets/MTransportFormSheet.tsx | 2 +- server/src/nest/budget/budget.service.ts | 2 +- .../nest/reservations/reservations.service.ts | 36 +++++++++++++++--- server/tests/e2e/reservations.e2e.test.ts | 15 ++++++++ .../unit/nest/reservations.service.test.ts | 37 +++++++++++++++++++ 12 files changed, 165 insertions(+), 29 deletions(-) diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index fd2d28ebe..1ac57913e 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useMemo, useId } from 'react' -import { localIsoDate } from '../../utils/localDate' import { useParams } from 'react-router' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' @@ -25,7 +24,7 @@ import { DialogShell, DialogFooter, FooterSpacer, DialogButton } from '../shared import { INPUT, GRID_2, GRID_3, EditorField, PillSelect } from '../shared/dialogParts' import { typeInfo } from './bookings/bookingsModel' import type { StatusTone } from './bookings/bookingParts' -import { RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill } from './reservationFormModel' +import { RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate } from './reservationFormModel' import { useReservationForm } from './useReservationForm' import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel' import { useBookingExpenseIntent } from './useBookingExpenseIntent' @@ -151,7 +150,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p } // Open the Costs editor for the saved booking when the user asked to // create/edit its linked expense (gated on saved?.id). - const expenseRequest = expenseRequestAfterSave(expense.take(), saved?.id, form) + const expenseRequest = expenseRequestAfterSave(expense.take(), saved, form, days) if (expenseRequest && onOpenExpense) onOpenExpense(expenseRequest) } finally { setIsSaving(false) @@ -266,7 +265,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p setStartTime(tm, days.find(dy => dy.id === selectedDayId)?.date || localIsoDate())} + onChange={tm => setStartTime(tm, selectedDayDate(days, selectedDayId))} /> diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 22d81ce98..8abedbd37 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -152,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel } // The user asked to create/edit the linked expense — open the Costs editor // for the now-saved booking. Gated on saved?.id so a failed save doesn't. - const expenseRequest = expenseRequestAfterSave(expense.take(), saved?.id, form) + const expenseRequest = expenseRequestAfterSave(expense.take(), saved, form, days) if (expenseRequest && onOpenExpense) onOpenExpense(expenseRequest) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) diff --git a/client/src/components/Planner/bookingFormModel.test.ts b/client/src/components/Planner/bookingFormModel.test.ts index 014d5c6b0..4b9fdc5b0 100644 --- a/client/src/components/Planner/bookingFormModel.test.ts +++ b/client/src/components/Planner/bookingFormModel.test.ts @@ -1,9 +1,9 @@ -// FE-PLANNER-BOOKINGFORM-001 to -008: the travellers, picked files, attached files +// FE-PLANNER-BOOKINGFORM-001 to -009: the travellers, picked files, attached files // and expense requests of the transport and booking forms, desktop and phone alike. import { typeToCostCategory } from '@trek/shared'; import { describe, expect, it, vi } from 'vitest'; -import { buildBudgetItem, buildReservation, buildTripFile } from '../../../tests/helpers/factories'; +import { buildBudgetItem, buildDay, buildReservation, buildTripFile } from '../../../tests/helpers/factories'; import type { Reservation } from '../../types'; import { attachedBookingFiles, @@ -67,13 +67,28 @@ describe('bookingFormModel', () => { it('FE-PLANNER-BOOKINGFORM-005: an expense wish opens the editor for the saved booking, nothing else does', () => { const booking = { title: 'Hotel Adlon', type: 'hotel' }; const item = buildBudgetItem({ id: 7 }); - expect(expenseRequestAfterSave({ create: true }, 42, booking)).toEqual({ + expect(expenseRequestAfterSave({ create: true }, { id: 42 }, booking, [])).toEqual({ prefill: { reservationId: 42, name: 'Hotel Adlon', category: typeToCostCategory('hotel') }, }); - expect(expenseRequestAfterSave({ editItem: item }, 42, booking)).toEqual({ editItem: item }); - expect(expenseRequestAfterSave(null, 42, booking)).toBeNull(); - expect(expenseRequestAfterSave({ create: true }, undefined, booking)).toBeNull(); - expect(expenseRequestAfterSave({ editItem: item }, null, booking)).toBeNull(); + expect(expenseRequestAfterSave({ editItem: item }, { id: 42 }, booking, [])).toEqual({ editItem: item }); + expect(expenseRequestAfterSave(null, { id: 42 }, booking, [])).toBeNull(); + expect(expenseRequestAfterSave({ create: true }, undefined, booking, [])).toBeNull(); + expect(expenseRequestAfterSave({ editItem: item }, null, booking, [])).toBeNull(); + }); + + it('FE-PLANNER-BOOKINGFORM-009: the expense opened for a booking is dated on the booking day (#2604)', () => { + const days = [buildDay({ id: 5, date: '2026-11-10' }), buildDay({ id: 6, date: '2026-11-11' })]; + const train = { title: 'KTX', type: 'train' }; + const dateOf = (saved: Parameters[1]) => + (expenseRequestAfterSave({ create: true }, saved, train, days) as { prefill: { date?: string } }).prefill.date; + expect(dateOf({ id: 1, reservation_time: '2026-11-12T10:00' })).toBe('2026-11-12'); + expect(dateOf({ id: 1, reservation_time: '2026-11-12' })).toBe('2026-11-12'); + // A hotel has no time of its own: it counts on its check-in day. + expect(dateOf({ id: 1, reservation_time: null, accommodation_start_day_id: 6 })).toBe('2026-11-11'); + // A time without a date sits on the day it was added to, or on none. + expect(dateOf({ id: 1, reservation_time: 'T09:00', day_id: 5 })).toBe('2026-11-10'); + expect(dateOf({ id: 1, reservation_time: 'T09:00', day_id: null })).toBeUndefined(); + expect(dateOf({ id: 1 })).toBeUndefined(); }); it('FE-PLANNER-BOOKINGFORM-006: an import review previews the parsed price, a saved booking does not', () => { diff --git a/client/src/components/Planner/bookingFormModel.ts b/client/src/components/Planner/bookingFormModel.ts index 71a952afe..fcac7433f 100644 --- a/client/src/components/Planner/bookingFormModel.ts +++ b/client/src/components/Planner/bookingFormModel.ts @@ -1,6 +1,6 @@ import { typeToCostCategory } from '@trek/shared'; -import type { BudgetItem, Reservation, TripFile } from '../../types'; +import type { BudgetItem, Day, Reservation, TripFile } from '../../types'; import type { BookingExpenseRequest } from './BookingCostsSection.types'; import { importedPriceEntry } from './importedPrice'; @@ -62,18 +62,42 @@ export interface BookingExpenseIntent { create?: boolean; } +type SavedBooking = Pick & + Partial>; + +/** + * The calendar day a saved booking falls on: its own date, else a hotel's check-in + * day, else the day it sits on. Nothing for a booking without any of them. + */ +export function bookingDate(saved: SavedBooking, days: Pick[]): string | undefined { + const own = (saved.reservation_time || '').slice(0, 10); + if (/^\d{4}-\d{2}-\d{2}$/.test(own)) return own; + const dayId = saved.accommodation_start_day_id ?? saved.day_id; + return (dayId != null && days.find((d) => d.id === dayId)?.date) || undefined; +} + /** * The expense editor request to open once a booking is saved, or null when the user - * asked for none or the save came back without a record. + * asked for none or the save came back without a record. A new expense is dated on + * the booking's day rather than today (#2604). */ export function expenseRequestAfterSave( intent: BookingExpenseIntent | null | undefined, - savedId: number | null | undefined, - booking: { title: string; type: string } + saved: SavedBooking | null | undefined, + booking: { title: string; type: string }, + days: Pick[] ): BookingExpenseRequest | null { - if (!intent || !savedId) return null; + if (!intent || !saved?.id) return null; if (intent.editItem) return { editItem: intent.editItem }; - return { prefill: { reservationId: savedId, name: booking.title, category: typeToCostCategory(booking.type) } }; + const date = bookingDate(saved, days); + return { + prefill: { + reservationId: saved.id, + name: booking.title, + category: typeToCostCategory(booking.type), + ...(date ? { date } : {}), + }, + }; } /** diff --git a/client/src/components/Planner/reservationFormModel.test.ts b/client/src/components/Planner/reservationFormModel.test.ts index c68387751..f1462d9bc 100644 --- a/client/src/components/Planner/reservationFormModel.test.ts +++ b/client/src/components/Planner/reservationFormModel.test.ts @@ -1,4 +1,4 @@ -// FE-PLANNER-RESFORM-001 to -014: the booking form both the desktop ReservationModal +// FE-PLANNER-RESFORM-001 to -015: the booking form both the desktop ReservationModal // and the phone's booking sheet save through. import { describe, expect, it } from 'vitest'; @@ -12,6 +12,7 @@ import { reservationFieldsFrom, reservationFieldsFromPrefill, reservationSaveData, + selectedDayDate, splitEndTime, startParts, tripDateBounds, @@ -187,6 +188,13 @@ describe('reservation dates', () => { expect(withStartTime('', '09:15', '')).toBe('T09:15'); }); + it('FE-PLANNER-RESFORM-015: a time typed without a day takes the picked day, never today (#2604)', () => { + expect(selectedDayDate(days, 3)).toBe('2026-07-02'); + expect(selectedDayDate(days, null)).toBe(''); + expect(selectedDayDate(days, 99)).toBe(''); + expect(withStartTime('', '09:15', selectedDayDate(days, null))).toBe('T09:15'); + }); + it('FE-PLANNER-RESFORM-007: the pickers are bounded by the earliest and latest dated day', () => { expect(tripDateBounds(days)).toEqual({ min: '2026-07-01', max: '2026-07-03' }); expect(tripDateBounds([{ date: null }] as { date: string }[])).toEqual({ min: undefined, max: undefined }); diff --git a/client/src/components/Planner/reservationFormModel.ts b/client/src/components/Planner/reservationFormModel.ts index 2e1fdba8c..4a876a637 100644 --- a/client/src/components/Planner/reservationFormModel.ts +++ b/client/src/components/Planner/reservationFormModel.ts @@ -176,6 +176,18 @@ export function withStartDate(reservationTime: string, date: string | null | und return date ? (time ? `${date}T${time}` : date) : ''; } +/** + * The date a start time typed on an undated booking falls on: the day the booking is + * being added to, else none. Never today, which is mostly not a trip day at all and + * was then moved onto the first one, so the list and the plan disagreed (#2604). + */ +export function selectedDayDate( + days: Pick[], + selectedDayId: number | string | null | undefined +): string { + return (selectedDayId != null && days.find((d) => d.id === selectedDayId)?.date) || ''; +} + /** The start with a new time, on its own date or `fallbackDate` when it has none. */ export function withStartTime(reservationTime: string, time: string, fallbackDate: string): string { const date = startParts(reservationTime).date || fallbackDate; diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index cbdb0d0ec..e6cc32c85 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -12,7 +12,7 @@ import MBookingFilesCosts from './MBookingFilesCosts' import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel' import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent' import { - RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, + RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate, } from '../../../../components/Planner/reservationFormModel' import { useReservationForm } from '../../../../components/Planner/useReservationForm' import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions' @@ -159,7 +159,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati if (saved?.id && canUploadFiles) { await uploadBookingFiles(fd => planner.tripActions.addFile(tripId, fd), saved.id, pendingFiles, form.title) } - const expenseRequest = expenseRequestAfterSave(withExpense, saved?.id, form) + const expenseRequest = expenseRequestAfterSave(withExpense, saved, form, days) if (expenseRequest) onOpenExpense(expenseRequest) if (importReviewActive && saved) advanceImportReview() } catch (err: unknown) { @@ -232,7 +232,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {t('reservations.startTime')} setStartTime(tm, days.find(dy => dy.id === selectedDayId)?.date || '')} + onChange={tm => setStartTime(tm, selectedDayDate(days, selectedDayId))} /> diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index ff697ca27..38f84fe6b 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -142,7 +142,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo if (saved?.id && canUploadFiles) { await uploadBookingFiles(fd => planner.tripActions.addFile(tripId, fd), saved.id, pendingFiles, form.title) } - const expenseRequest = expenseRequestAfterSave(withExpense, saved?.id, form) + const expenseRequest = expenseRequestAfterSave(withExpense, saved, form, days) if (expenseRequest) onOpenExpense(expenseRequest) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 8c0ecd550..62b0f5627 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -711,7 +711,7 @@ export class BudgetService { async linkBudgetItemToReservation( tripId: string | number, reservationId: number, - data: { name: string; category?: string; total_price: number; currency?: string | null; exchange_rate?: number }, + data: { name: string; category?: string; total_price: number; currency?: string | null; exchange_rate?: number; expense_date?: string | null }, ) { // createBudgetItem accepts reservation_id directly — the legacy separate // UPDATE after the insert was redundant (and non-atomic). diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 50a9a6fec..03a2863c9 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -42,6 +42,8 @@ import { decodeJson } from '../../utils/json-column'; type Trip = TripAccess; type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined; +/** A booking's cost as the service writes it: the entry, dated on the booking's day (bookingDate), never by the caller. */ +type DatedBudgetEntry = (NonNullable & { expense_date?: string | null }) | undefined; /** What a booking write did to its linked costs, for the caller to send once the write has committed. */ export type CostEvent = @@ -666,7 +668,7 @@ export class ReservationsService { async createWithCostInTx(tripId: string, data: CreateReservationData, entry: BudgetEntry) { const costEvents: CostEvent[] = []; const done = await this.createInTx(tripId, data); - await this.syncBudgetOnCreate(tripId, done.reservation.id, data.title, data.type, entry, undefined, costEvents); + await this.syncBudgetOnCreate(tripId, done.reservation.id, data.title, data.type, await this.dated(entry, done.reservation), undefined, costEvents); return { ...done, costEvents }; } @@ -675,7 +677,7 @@ export class ReservationsService { const costEvents: CostEvent[] = []; const { stayMirror, ...written } = await this.uow.transactional(async () => { const done = await this.updateInTx(id, tripId, data, current); - await this.syncBudgetOnUpdate(tripId, id, data.title ?? '', data.type, current.title, current.type ?? undefined, entry, undefined, costEvents); + await this.syncBudgetOnUpdate(tripId, id, data.title ?? '', data.type, current.title, current.type ?? undefined, await this.dated(entry, done.reservation), undefined, costEvents); return done; }); await this.announceStayMirror(tripId, stayMirror); @@ -1133,8 +1135,26 @@ export class ReservationsService { return { ...rest, currency, ...(priced.exchange_rate != null ? { exchange_rate: priced.exchange_rate } : {}) }; } + /** + * The calendar day a booking falls on, which a cost created with it is dated on + * (#2604): its own date, else a hotel's check-in day, else the day it sits on. + * Null for a booking without any of them; the cost then has no date, as before. + */ + private async bookingDate(reservation: ReservationRow): Promise { + const own = String(reservation.reservation_time ?? '').slice(0, 10); + if (/^\d{4}-\d{2}-\d{2}$/.test(own)) return own; + const dayId = Number(reservation.accommodation_start_day_id ?? reservation.day_id) || null; + return dayId ? ((await this.daysRepo.findById(dayId))?.date ?? null) : null; + } + + /** The entry with the booking's day as its date, replacing anything the caller sent. */ + private async dated(entry: BudgetEntry, reservation: ReservationRow): Promise { + if (!entry || typeof entry !== 'object' || !(Number(entry.total_price) > 0)) return entry; + return { ...entry, expense_date: await this.bookingDate(reservation) }; + } + /** POST side effect: link a budget item when a price is provided. `collect` holds the broadcast for the caller's transaction. */ - async syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined, collect?: CostEvent[]): Promise { + async syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: DatedBudgetEntry, socketId: string | undefined, collect?: CostEvent[]): Promise { if (!entry || !(Number(entry.total_price) > 0)) return; // A failing cost write is not caught: it rolls the booking back with it in createWithCost. const item = await this.budget.linkBudgetItemToReservation(tripId, reservationId, { @@ -1143,12 +1163,13 @@ export class ReservationsService { total_price: entry.total_price!, ...(entry.currency ? { currency: entry.currency } : {}), ...(entry.exchange_rate != null ? { exchange_rate: entry.exchange_rate } : {}), + ...(entry.expense_date ? { expense_date: entry.expense_date } : {}), }); this.sendCost(tripId, { event: 'budget:created', payload: { item } }, socketId, collect); } /** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. `collect` and failures as on create. */ - async syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined, collect?: CostEvent[]): Promise { + async syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: DatedBudgetEntry, socketId: string | undefined, collect?: CostEvent[]): Promise { // When the booking type changes, keep a linked expense's category in sync — // but only if it still carries the auto-derived category (so a manual pick in // the Costs editor is preserved). Runs regardless of create_budget_entry. @@ -1189,7 +1210,12 @@ export class ReservationsService { this.sendCost(tripId, { event: 'budget:updated', payload: { item: updated } }, socketId, collect); } else { // The link travels on the insert, so a cost is never written without its booking. - const item = await this.budget.linkBudgetItemToReservation(tripId, Number(id), { name: itemName, category, total_price: entry.total_price! }); + const item = await this.budget.linkBudgetItemToReservation(tripId, Number(id), { + name: itemName, + category, + total_price: entry.total_price!, + ...(entry.expense_date ? { expense_date: entry.expense_date } : {}), + }); this.sendCost(tripId, { event: 'budget:created', payload: { item } }, socketId, collect); } } diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index bef8528d1..b030e5484 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -215,6 +215,21 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real expect(item).toEqual({ total_price: 20, currency: null, exchange_rate: 1 }); }); + it('201 create dates the booked cost on the booking day, not on no day at all (#2604)', async () => { + const res = await request(server) + .post(`/api/trips/${tripId}/reservations`) + .set('Cookie', sessionCookie(1)) + .send({ + title: 'KTX', + type: 'train', + reservation_time: '2026-11-12T10:00', + create_budget_entry: { total_price: 59800 }, + }); + expect(res.status).toBe(201); + const item = await findRow(orm, BudgetItems, { reservation: res.body.reservation.id }); + expect(item?.expense_date).toBe('2026-11-12'); + }); + it('201 create reservation (real insert + booking notification), 400 without title', async () => { const ok = await request(server) .post(`/api/trips/${tripId}/reservations`) diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index ab09fc2a8..f0117013d 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -817,6 +817,43 @@ describe('ReservationsService (DI-native, real SQL)', () => { expect(out.costEvents).toEqual([]); expect(budget.linkBudgetItemToReservation).not.toHaveBeenCalled(); }); + + it('RESV-TX-007: the cost is dated on the booking day, a hotel on its check-in day (#2604)', async () => { + const { trip } = ownerTrip(); + const first = createDay(testDb, trip.id, { date: '2026-11-10' }); + const second = createDay(testDb, trip.id, { date: '2026-11-11' }); + budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 }); + const linkedWith = () => budget.linkBudgetItemToReservation.mock.calls.at(-1)?.[2]; + + await svc.createWithCost(String(trip.id), { title: 'KTX', type: 'train', reservation_time: '2026-11-12T10:00' } as never, { total_price: 59800 }); + expect(linkedWith()).toMatchObject({ name: 'KTX', total_price: 59800, expense_date: '2026-11-12' }); + + await svc.createWithCost( + String(trip.id), + { title: 'Hanok', type: 'hotel', create_accommodation: { start_day_id: second.id, end_day_id: second.id } } as never, + { total_price: 120000 }, + ); + expect(linkedWith()).toMatchObject({ name: 'Hanok', expense_date: '2026-11-11' }); + + // A time without a date sits on the day it was added to. + await svc.createWithCost(String(trip.id), { title: 'Lunch', type: 'restaurant', reservation_time: 'T12:00', day_id: first.id } as never, { total_price: 9000 }); + expect(linkedWith()).toMatchObject({ name: 'Lunch', expense_date: '2026-11-10' }); + + // Nothing to date it by: no date, as before. + await svc.createWithCost(String(trip.id), { title: 'Tip', type: 'other' } as never, { total_price: 5000 }); + expect(linkedWith()).not.toHaveProperty('expense_date'); + }); + + it('RESV-TX-008: a price added to a booking later dates its new cost the same way (#2604)', async () => { + const { trip } = ownerTrip(); + budget.linkBudgetItemToReservation.mockReturnValue({ id: 8 }); + const created = await svc.create(String(trip.id), { title: 'Ferry', type: 'ferry', reservation_time: '2026-11-13T08:30' } as never); + const current = (await svc.getReservation(String(created.reservation.id), String(trip.id)))!; + + await svc.updateWithCost(String(created.reservation.id), String(trip.id), { title: 'Ferry' } as never, current, { total_price: 30000 }); + + expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith(String(trip.id), created.reservation.id, expect.objectContaining({ expense_date: '2026-11-13' })); + }); }); describe('notifyBookingChange', () => {