fix(reservations): link a cost created on a booking update on its insert

syncBudgetOnUpdate created the expense and then set its reservation_id in a
second statement, inside a try that logs and swallows the failure. Running
inside updateWithCost, a failed back-link committed an expense that was not
linked to its booking. The expense is now created through
linkBudgetItemToReservation, which writes the link on the insert, the same
way syncBudgetOnCreate does.
This commit is contained in:
Maurice
2026-10-08 15:56:18 +02:00
parent b84867b4c2
commit c7feaf0a65
3 changed files with 10 additions and 11 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"src/nest/places/places.service.ts": 2020,
"src/nest/plugins/plugin-runtime.service.ts": 1463,
"src/nest/plugins/runtime/plugin-sdk.ts": 1094,
"src/nest/reservations/reservations.service.ts": 1248,
"src/nest/reservations/reservations.service.ts": 1247,
"src/nest/trips/trips.service.ts": 1013,
"src/nest/vacay/vacay.service.ts": 1440
}
@@ -1192,9 +1192,8 @@ export class ReservationsService {
const updated = await this.budget.updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
this.sendCost(tripId, { event: 'budget:updated', payload: { item: updated } }, socketId, collect);
} else {
const item = await this.budget.createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
await this.budgetItemsRepo.setReservationId(item.id, id);
item.reservation_id = Number(id);
// 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! });
this.sendCost(tripId, { event: 'budget:created', payload: { item } }, socketId, collect);
}
} catch (err) {
@@ -695,16 +695,16 @@ describe('ReservationsService (DI-native, real SQL)', () => {
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id } }, 'sock');
});
it('creates + links a new item when none exists, using the current title fallback', async () => {
it('creates a new item linked on its insert when none exists, using the current title fallback', async () => {
const { trip } = ownerTrip();
const res = createReservation(testDb, trip.id);
const created = createBudgetItem(testDb, trip.id); // the row the mocked create "returns"
budget.createBudgetItem.mockReturnValue({ id: created.id });
budget.linkBudgetItemToReservation.mockReturnValue({ id: 31, reservation_id: res.id });
await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), '', undefined, 'Old title', 'flight', { total_price: 120 }, 'sock');
expect(budget.createBudgetItem).toHaveBeenCalledWith(String(trip.id), { name: 'Old title', category: 'flight', total_price: 120 });
// The service back-links the created item to the reservation itself.
expect(testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(created.id)).toEqual({ reservation_id: res.id });
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:created', { item: { id: created.id, reservation_id: res.id } }, 'sock');
// One statement carries the link: no unlinked insert followed by a separate
// back-link that a swallowed failure inside updateWithCost could leave half done.
expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith(String(trip.id), res.id, { name: 'Old title', category: 'flight', total_price: 120 });
expect(budget.createBudgetItem).not.toHaveBeenCalled();
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:created', { item: { id: 31, reservation_id: res.id } }, 'sock');
});
it('RESV-TX-001: updateWithCost rolls the booking edit back when its cost write fails', async () => {