mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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'))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user