diff --git a/backend/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts
index 57bbd82e..f315596f 100644
--- a/backend/controllers/parking/bookings.controller.ts
+++ b/backend/controllers/parking/bookings.controller.ts
@@ -214,8 +214,8 @@ class ParkingBookingsController {
expected_checkin,
expected_checkout,
total_amount: total_amount ? parseFloat(total_amount) : undefined,
- source: booking_source,
- external_id: external_booking_id,
+ booking_source,
+ external_booking_id,
notes,
})
diff --git a/backend/tests/parking/booking-source.test.ts b/backend/tests/parking/booking-source.test.ts
new file mode 100644
index 00000000..9cd74a80
--- /dev/null
+++ b/backend/tests/parking/booking-source.test.ts
@@ -0,0 +1,87 @@
+// tests/parking/booking-source.test.ts
+// Regression tests: creating a booking always stored booking_source 'direct'
+// and dropped the external reference (the controller handed them to Zod under
+// other names), and the forms offered phone, email and walk-in, which the
+// parking_bookings.booking_source ENUM does not hold. Decided on 2026-10-11:
+// the forms offer the ENUM values only.
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { Request, Response } from 'express'
+import { readFileSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+vi.mock('../../repositories/parking/bookings.repository.js', () => ({
+ default: { create: vi.fn(async () => ({ id: 1 })) },
+}))
+
+const { default: controller } = await import('../../controllers/parking/bookings.controller.js')
+const { default: repository } = await import('../../repositories/parking/bookings.repository.js')
+const { BOOKING_SOURCES, createBookingSchema, updateBookingBodySchema } =
+ await import('../../validations/parking/booking-validation.js')
+
+const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)
+const body = {
+ spot_number: 1,
+ level_code: '-3',
+ vehicle_id: 1,
+ expected_checkin: `${tomorrow}T10:00:00`,
+ expected_checkout: `${tomorrow}T18:00:00`,
+}
+
+function create(extra: object) {
+ const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() }
+ const req = { user: { id: 'u1' }, body: { ...body, ...extra } } as unknown as Request
+ return controller.createBooking(req, res as unknown as Response).then(() => res)
+}
+
+describe('booking source', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('create keeps the source and the external reference the form sends', async () => {
+ const res = await create({ booking_source: 'airbnb', external_booking_id: 'HM-123' })
+
+ expect(res.status).toHaveBeenCalledWith(201)
+ expect(vi.mocked(repository.create).mock.calls[0][0]).toMatchObject({
+ booking_source: 'airbnb',
+ external_booking_id: 'HM-123',
+ })
+ })
+
+ it('create defaults to direct and refuses a value outside the ENUM', async () => {
+ await create({})
+ expect(vi.mocked(repository.create).mock.calls[0][0]).toMatchObject({
+ booking_source: 'direct',
+ })
+
+ const res = await create({ booking_source: 'phone' })
+ expect(res.status).toHaveBeenCalledWith(400)
+ expect(repository.create).toHaveBeenCalledOnce()
+ })
+
+ it('both schemas take exactly the ENUM values', () => {
+ for (const source of BOOKING_SOURCES) {
+ expect(createBookingSchema.safeParse({ ...body, booking_source: source }).success).toBe(true)
+ expect(updateBookingBodySchema.safeParse({ booking_source: source }).success).toBe(true)
+ }
+ for (const source of ['phone', 'email', 'walkin', 'booking', 'other']) {
+ expect(updateBookingBodySchema.safeParse({ booking_source: source }).success).toBe(false)
+ }
+ })
+
+ it('the forms offer the ENUM values only', () => {
+ const frontend = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'frontend')
+ for (const file of [
+ ['booking', 'BookingWizard', 'steps', 'DateOnlyStep.tsx'],
+ ['booking', 'BookingWizard', 'steps', 'DateSpotStep.tsx'],
+ ['parking', 'bookings', 'EditBookingModal.tsx'],
+ ]) {
+ const src = readFileSync(join(frontend, 'app', 'components', ...file), 'utf8')
+ const name = file.at(-1)
+ for (const source of BOOKING_SOURCES) {
+ expect(src, `${name} offers ${source}`).toMatch(new RegExp(`value(=|: )["']${source}["']`))
+ }
+ expect(src, name).not.toMatch(/value(=|: )["'](booking|phone|email|walkin|other)["']/)
+ }
+ })
+})
diff --git a/backend/validations/parking/booking-validation.ts b/backend/validations/parking/booking-validation.ts
index d7b17b68..d6be5e1f 100644
--- a/backend/validations/parking/booking-validation.ts
+++ b/backend/validations/parking/booking-validation.ts
@@ -8,6 +8,16 @@ import timezone from 'dayjs/plugin/timezone.js'
dayjs.extend(utc)
dayjs.extend(timezone)
+// The values of the parking_bookings.booking_source ENUM
+export const BOOKING_SOURCES = [
+ 'direct',
+ 'booking_com',
+ 'expedia',
+ 'airbnb',
+ 'agency_other',
+] as const
+const bookingSource = z.enum(BOOKING_SOURCES)
+
const dateTimeSchema = z
.string()
.min(1, 'La fecha es requerida')
@@ -22,8 +32,8 @@ export const createBookingSchema = z
expected_checkin: dateTimeSchema,
expected_checkout: dateTimeSchema,
total_amount: z.number().positive().optional(),
- booking_source: z.string().optional().default('direct'),
- external_booking_id: z.string().optional(),
+ booking_source: bookingSource.optional().default('direct'),
+ external_booking_id: z.string().max(64).optional(),
notes: z.string().max(500).optional(),
})
.refine(
@@ -53,8 +63,7 @@ export const createBookingSchema = z
// DECIMAL(10,2) and never negative, payment_method is an ENUM, references and
// external ids are VARCHAR(100)/(64). Before, any value reached MySQL (a
// negative payment was stored, a bad ENUM or text ended in a 500).
-// booking_source is left as it is: its allowed values are still to be decided
-// (docs/TODO.md)
+// booking_source is one of the ENUM values
const amount = z.union([z.null(), z.coerce.number().finite().min(0).max(99_999_999.99)])
export const updateBookingBodySchema = z.object({
@@ -64,7 +73,7 @@ export const updateBookingBodySchema = z.object({
level_code: z.enum(['-2', '-3']).optional(),
vehicle_id: z.union([z.null(), z.coerce.number().int().positive()]).optional(),
total_amount: amount.optional(),
- booking_source: z.string().optional(),
+ booking_source: bookingSource.optional(),
external_booking_id: z.string().max(64).nullable().optional(),
notes: z.string().max(16000).nullable().optional(),
payment_amount: amount.optional(),
diff --git a/docs/TODO.md b/docs/TODO.md
index 473ba85c..1768debc 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -152,16 +152,6 @@ preview.
- [ ] **Parking: `GET /vehicles` vuelca todos los vehículos** (matrícula y
titular) sin paginar ni filtrar. _Según la revisión `security` L3 del
2026-09-28 (fichero y línea en el informe); no repasado por mí._
-- [ ] **Parking: el origen y la referencia externa se pierden al crear** — el
- frontend (`useBookingWizard.ts`) envía `source`
- y `external_id`; el controlador lee `booking_source` y
- `external_booking_id` y además los pasa a Zod con los nombres cambiados:
- siempre queda `direct`. **Decidir antes de arreglar:** los formularios
- ofrecen `phone`, `email`, `walkin` y `booking` (`DateOnlyStep.tsx`), que el
- `ENUM` de `parking_bookings.booking_source` no admite (`direct`,
- `booking_com`, `expedia`, `airbnb`, `agency_other`); conservar el origen
- sin ampliar el `ENUM` o mapear esos valores haría fallar la reserva.
- _Comprobado por mí el 2026-09-28; detalle del 2026-10-02._
- [ ] **Mensajería: crear una conversación no va en transacción** — el formato
y los repetidos de `participant_ids` y `user_ids` ya se validan, pero un
usuario inexistente o inactivo deja la conversación creada a medias.
diff --git a/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts b/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts
index 79fd03cb..04165774 100644
--- a/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts
+++ b/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts
@@ -376,9 +376,9 @@ export const useBookingWizard = ({
vehicle_id: state.vehicleId ?? undefined,
expected_checkin: checkinISO,
expected_checkout: checkoutISO,
- source: state.reservationData.booking_source || 'direct',
+ booking_source: state.reservationData.booking_source || 'direct',
total_amount: totalAmount,
- external_id: state.reservationData.external_booking_id || undefined,
+ external_booking_id: state.reservationData.external_booking_id || undefined,
notes: state.reservationData.notes || undefined,
}
diff --git a/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx b/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx
index 91db08b3..e6eff228 100644
--- a/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx
+++ b/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx
@@ -165,11 +165,10 @@ export default function DateOnlyStep({ state, actions }: DateOnlyStepProps) {
className={selectClassName}
>
-
+
-
-
-
+
+
diff --git a/frontend/app/components/parking/bookings/EditBookingModal.tsx b/frontend/app/components/parking/bookings/EditBookingModal.tsx
index bde6115a..47382db1 100644
--- a/frontend/app/components/parking/bookings/EditBookingModal.tsx
+++ b/frontend/app/components/parking/bookings/EditBookingModal.tsx
@@ -306,10 +306,7 @@ export function EditBookingModal({
{ value: 'booking_com', label: t('bookingSources.booking_com') },
{ value: 'expedia', label: t('bookingSources.expedia') },
{ value: 'airbnb', label: t('bookingSources.airbnb') },
- { value: 'phone', label: t('bookingSources.phone') },
- { value: 'email', label: t('bookingSources.email') },
- { value: 'walkin', label: t('bookingSources.walkin') },
- { value: 'other', label: t('bookingSources.other') },
+ { value: 'agency_other', label: t('bookingSources.agency_other') },
]}
label={t('editModal.bookingSource')}
size="md"
diff --git a/frontend/app/lib/parking/types.ts b/frontend/app/lib/parking/types.ts
index aeec8d37..be8542e9 100644
--- a/frontend/app/lib/parking/types.ts
+++ b/frontend/app/lib/parking/types.ts
@@ -318,8 +318,8 @@ export interface CreateBookingDto {
expected_checkin: string
expected_checkout: string
total_amount?: number
- source?: string
- external_id?: string
+ booking_source?: string
+ external_booking_id?: string
notes?: string
}
diff --git a/frontend/messages/en/booking.json b/frontend/messages/en/booking.json
index 0ae703c6..f9e69896 100644
--- a/frontend/messages/en/booking.json
+++ b/frontend/messages/en/booking.json
@@ -84,10 +84,7 @@
"booking_com": "Booking.com",
"airbnb": "Airbnb",
"expedia": "Expedia",
- "agency_other": "Other Agency",
- "phone": "Phone",
- "email": "Email",
- "walkin": "Walk-in"
+ "agency_other": "Other agency"
},
"confirmation": {
"title": "Confirm Booking",
diff --git a/frontend/messages/en/parking.json b/frontend/messages/en/parking.json
index d88e5dd9..a398a634 100644
--- a/frontend/messages/en/parking.json
+++ b/frontend/messages/en/parking.json
@@ -407,10 +407,7 @@
"booking_com": "Booking.com",
"expedia": "Expedia",
"airbnb": "Airbnb",
- "phone": "Phone",
- "email": "Email",
- "walkin": "Walk-in",
- "other": "Other"
+ "agency_other": "Other agency"
},
"statusPageNav": {
"title": "Parking Control",
diff --git a/frontend/messages/es/booking.json b/frontend/messages/es/booking.json
index 722cc50c..833f573c 100644
--- a/frontend/messages/es/booking.json
+++ b/frontend/messages/es/booking.json
@@ -84,10 +84,7 @@
"booking_com": "Booking.com",
"airbnb": "Airbnb",
"expedia": "Expedia",
- "agency_other": "Otra Agencia",
- "phone": "Teléfono",
- "email": "Email",
- "walkin": "Walk-in"
+ "agency_other": "Otra agencia"
},
"confirmation": {
"title": "Confirmar Reserva",
diff --git a/frontend/messages/es/parking.json b/frontend/messages/es/parking.json
index 8afbc368..c34e27be 100644
--- a/frontend/messages/es/parking.json
+++ b/frontend/messages/es/parking.json
@@ -407,10 +407,7 @@
"booking_com": "Booking.com",
"expedia": "Expedia",
"airbnb": "Airbnb",
- "phone": "Teléfono",
- "email": "Email",
- "walkin": "Walk-in",
- "other": "Otro"
+ "agency_other": "Otra agencia"
},
"statusPageNav": {
"title": "Control de Parking",