Files
TREK/server/src/nest/reservations/reservations.service.ts
T
Maurice c15c89ca61 feat(costs): create an expense from a booking, fix editing total-only items
Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.

Also fix the Costs editor so an expense with a recorded total but no payers
(transport-derived or pre-rework items) opens with its amount, lets you set the
currency, and saves - it previously showed 0 everywhere and could not be saved.
Legacy / localized categories now map to the fixed keys, and changing a booking's
type keeps its linked expense category in sync (unless it was manually set).

- shared: reservation_id on budget create, typeToCostCategory helper, i18n keys
- server: createBudgetItem stores reservation_id; keep total_price for payerless
  items; a booking update no longer wipes its linked expense and syncs the
  category on type change
- client: shared BookingCostsSection, exported ExpenseModal with prefill and an
  editable total, page-level save-then-open wiring
2026-06-17 22:11:56 +02:00

143 lines
6.6 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import { broadcast } from '../../websocket';
import { checkPermission } from '../../services/permissions';
import type { User } from '../../types';
import * as svc from '../../services/reservationService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
import { typeToCostCategory } from '@trek/shared';
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
type BudgetEntry = { total_price?: number; category?: string } | undefined;
/**
* Thin Nest wrapper around the existing reservation service. Trip-access, the
* 'reservation_edit' permission, the SQL and the WebSocket broadcasts reuse the
* legacy code unchanged. The legacy route's budget side effects (auto-create /
* update / delete a linked budget item) and the booking notification are
* encapsulated here so the controller stays thin — behaviour is 1:1.
*/
@Injectable()
export class ReservationsService {
verifyTripAccess(tripId: string, userId: number) {
return svc.verifyTripAccess(tripId, userId);
}
canEdit(trip: Trip, user: User): boolean {
return checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
}
list(tripId: string) {
return svc.listReservations(tripId);
}
// Cross-trip "upcoming reservations" feed (dashboard widget). Reuses the legacy
// query unchanged; the default limit (6) matches the legacy inline handler.
listUpcoming(userId: number) {
return svc.getUpcomingReservations(userId);
}
create(tripId: string, data: Parameters<typeof svc.createReservation>[1]) {
return svc.createReservation(tripId, data);
}
updatePositions(tripId: string, positions: Parameters<typeof svc.updatePositions>[1], dayId: unknown): void {
svc.updatePositions(tripId, positions, dayId as Parameters<typeof svc.updatePositions>[2]);
}
getReservation(id: string, tripId: string) {
return svc.getReservation(id, tripId);
}
update(id: string, tripId: string, data: Parameters<typeof svc.updateReservation>[2], current: Parameters<typeof svc.updateReservation>[3]) {
return svc.updateReservation(id, tripId, data, current);
}
remove(id: string, tripId: string) {
return svc.deleteReservation(id, tripId);
}
/** POST side effect: auto-create a linked budget item when a price is provided. */
syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
if (!entry || !(Number(entry.total_price) > 0)) return;
try {
const item = linkBudgetItemToReservation(tripId, reservationId, {
name: title,
category: entry.category || type || 'Other',
total_price: entry.total_price!,
});
broadcast(tripId, 'budget:created', { item }, socketId);
} catch (err) {
console.error('[reservations] Failed to create budget entry:', err);
}
}
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): 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.
if (type && currentType && type !== currentType) {
const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
if (linked) {
const oldCat = typeToCostCategory(currentType);
const newCat = typeToCostCategory(type);
if (oldCat !== newCat && linked.category === oldCat) {
const updated = updateBudgetItem(linked.id, tripId, { category: newCat });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
}
}
}
// No budget entry on the payload — the booking edit isn't touching its linked
// expense, so leave any linked item alone. Expenses are managed from the
// booking's Costs section / the Costs tab, not by re-saving the booking.
if (!entry) return;
if (!(Number(entry.total_price) > 0)) {
// Explicit clear (total_price 0/empty) — drop the linked item.
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (linked) {
deleteBudgetItem(linked.id, tripId);
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
}
return;
}
try {
const itemName = title || currentTitle;
const category = entry.category || type || currentType || 'Other';
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) {
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
} else {
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
item.reservation_id = Number(id);
broadcast(tripId, 'budget:created', { item }, socketId);
}
} catch (err) {
console.error('[reservations] Failed to create/update budget entry:', err);
}
}
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
notifyBookingChange(tripId: string, actor: User, booking: string, type: string): void {
import('../../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({
event: 'booking_change',
actorId: actor.id,
scope: 'trip',
targetId: Number(tripId),
params: { trip: tripInfo?.title || 'Untitled', actor: actor.email, booking, type: type || 'booking', tripId: String(tripId) },
}).catch(() => {});
});
}
}