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
This commit is contained in:
Maurice
2026-10-12 15:04:22 +02:00
parent 33e606959c
commit 9064dbb2f0
12 changed files with 165 additions and 29 deletions
@@ -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
<EditorField label={t('reservations.startTime')}>
<CustomTimePicker
value={startTime}
onChange={tm => setStartTime(tm, days.find(dy => dy.id === selectedDayId)?.date || localIsoDate())}
onChange={tm => setStartTime(tm, selectedDayDate(days, selectedDayId))}
/>
</EditorField>
<EditorField label={t('reservations.endDate')} error={isEndBeforeStart ? t('reservations.validation.endBeforeStart') : undefined}>
@@ -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'))
@@ -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<typeof expenseRequestAfterSave>[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', () => {
@@ -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<Reservation, 'id'> &
Partial<Pick<Reservation, 'reservation_time' | 'day_id' | 'accommodation_start_day_id'>>;
/**
* 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<Day, 'id' | 'date'>[]): 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<Day, 'id' | 'date'>[]
): 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 } : {}),
},
};
}
/**
@@ -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 });
@@ -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<Day, 'id' | 'date'>[],
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;
@@ -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
<Eyebrow className="mb-[5px] uppercase">{t('reservations.startTime')}</Eyebrow>
<CustomTimePicker
value={startTime}
onChange={tm => setStartTime(tm, days.find(dy => dy.id === selectedDayId)?.date || '')}
onChange={tm => setStartTime(tm, selectedDayDate(days, selectedDayId))}
/>
</div>
</div>
@@ -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'))
+1 -1
View File
@@ -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).
@@ -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<BudgetEntry> & { 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<string | null> {
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<DatedBudgetEntry> {
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<void> {
async syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: DatedBudgetEntry, socketId: string | undefined, collect?: CostEvent[]): Promise<void> {
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<void> {
async syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: DatedBudgetEntry, socketId: string | undefined, collect?: CostEvent[]): Promise<void> {
// 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);
}
}
+15
View File
@@ -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`)
@@ -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', () => {