Files
TREK/server/src/nest/reservations/reservation-metadata.ts
T
Maurice 3e1c06a0ee fix(reservations): keep a mirrored booking price without keying it on the expense
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.

The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.

What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.

The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
2026-09-06 16:11:52 +02:00

56 lines
2.5 KiB
TypeScript

/**
* Metadata keys on a reservation that the booking forms do not own.
*
* `price` and `priceCurrency` are written by the expense side
* (BudgetService.syncReservationPrice) and by the booking importer, never by a
* booking form. The forms rebuild metadata from their own fields on every save
* and carry only `transit` and `airtrail_ids` across, so an ordinary edit used
* to drop the price and the card lost it until the expense was saved again
* (#2233). The same happened unattended on the AirTrail poll, which updates a
* reservation with a mapped metadata object that never contains a price.
*
* Kept out of the reservations service so it can be exercised on its own.
*/
/** Read a stored metadata column into an object, or null if it is not one. */
function parseStored(stored: string | null | undefined): Record<string, unknown> | null {
if (!stored) return null;
try {
const parsed: unknown = JSON.parse(stored);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/**
* Carry `price` / `priceCurrency` from the stored metadata into an incoming
* object that does not name them.
*
* Everything else passes through untouched, which is what keeps the caller in
* control: `undefined` (no metadata on the payload) leaves the row alone, `null`
* clears the column, and naming the key — including setting it to null — is
* taken at face value, so a price can still be removed deliberately.
*
* There is deliberately no "only while an expense is linked" condition. The
* importer stamps a price onto the booking whether or not the Costs addon is
* enabled, and an MCP booking created with a price of 0 gets no linked item
* either, so that condition would drop a real price on the first edit. What
* stops a stale price from outliving its expense is the other end:
* BudgetService.deleteBudgetItem clears the mirror when the expense goes away.
*/
export function keepMirroredPrice(incoming: unknown, stored: string | null | undefined): unknown {
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return incoming;
const next = incoming as Record<string, unknown>;
if ('price' in next) return incoming;
const prev = parseStored(stored);
if (!prev || prev.price === undefined) return incoming;
const kept: Record<string, unknown> = { ...next, price: prev.price };
if (prev.priceCurrency !== undefined && !('priceCurrency' in next)) kept.priceCurrency = prev.priceCurrency;
return kept;
}