/** * Anlage einer Buchung samt Platzreservierung (Briefing Abschnitte 5–8). * * Nebenlaeufigkeit: Jede Transaktion, die Kapazitaet belegt, sperrt zuerst * die Camp-Zeile (SELECT … FOR UPDATE) und danach ggf. die Gutscheinzeile. * Parallele Checkouts laufen dadurch fuer den kurzen Moment der Pruefung * strikt nacheinander — zwei Personen koennen nie denselben letzten Platz * oder dieselbe letzte Einloesung erhalten. Die feste Sperrreihenfolge * (bestehende Buchung -> Camp -> Gutschein) schliesst Deadlocks aus: neue * Buchungen sperren nur Camp und Gutschein, Zahlungs- und Adminaktionen * sperren zuerst die Buchung und erst danach ggf. Camp bzw. Gutschein. */ import "./zod-de"; import { z } from "zod"; import type { Booking, Installment, PlanType as DbPlanType } from "@/generated/prisma/client"; import { audit, CUSTOMER } from "./audit"; import { campStartIso, getCamp, salesStateFor } from "./camp"; import { CAMP_ID, type Db, type Tx } from "./db"; import { buildSchedule, INSTALLMENT_COUNT, PLAN_TYPES, type PlanType, type ScheduledInstallment } from "./payment-plan"; import { calculatePrice, type PriceBreakdown } from "./pricing"; import { sellablePhase, type SalesState, type StripeMode } from "./sales-gate"; import { generateReference, sha256Hex, statusTokenFor } from "./tokens"; import { evaluateVoucher, normalizeEmail, type VoucherRejection } from "./vouchers"; /** Puffer zwischen Ablauf der Stripe-Session und Freigabe des Platzes, damit * ein kurz vor Ablauf abgeschlossener Checkout seinen Platz sicher behaelt. */ export const RESERVATION_GRACE_MINUTES = 10; export const participantSchema = z.object({ firstName: z.string().trim().min(1, "Bitte gib deinen Vornamen an.").max(80), lastName: z.string().trim().min(1, "Bitte gib deinen Nachnamen an.").max(80), email: z.email("Bitte gib eine gültige E-Mail-Adresse an.").max(200).transform(normalizeEmail), phone: z .string() .trim() .min(6, "Bitte gib eine Telefonnummer an.") .max(40) .regex(/^[+0-9 ()/-]+$/, "Die Telefonnummer darf nur Ziffern, Leerzeichen und + ( ) / - enthalten."), country: z.string().trim().min(2, "Bitte gib dein Land an.").max(60), notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)), }); export const bookingRequestSchema = z .object({ idempotencyKey: z.uuid(), participant: participantSchema, planType: z.enum(PLAN_TYPES as [PlanType, ...PlanType[]]), voucherCode: z.string().trim().max(60).optional().transform((v) => (v ? v : undefined)), consents: z.object({ adult: z.literal(true, "Bitte bestätige, dass du volljährig bist."), terms: z.literal(true, "Bitte akzeptiere die Buchungsbedingungen."), accommodation: z.literal(true, "Bitte bestätige die Unterbringung."), privacy: z.literal(true, "Bitte bestätige die Datenschutzhinweise."), installmentMandate: z.boolean(), }), }) .refine((v) => v.planType === "FULL" || v.consents.installmentMandate, { message: "Bitte stimme dem Einzug der weiteren Raten zu.", path: ["consents", "installmentMandate"], }); export type BookingRequest = z.infer; export type BookingError = | { code: "SALES_CLOSED"; reason: SalesState["closedReason"] } | { code: "SOLD_OUT" } | { code: "VOUCHER_REJECTED"; reason: VoucherRejection } | { code: "PLAN_UNAVAILABLE" } | { code: "IDEMPOTENCY_CONFLICT" }; export type CreatedBooking = Booking & { installments: Installment[] }; export type CreateBookingResult = | { ok: true; reused: boolean; booking: CreatedBooking } | { ok: false; error: BookingError }; export type BookingContext = { now: Date; mode: StripeMode; secret: string }; /** Belegte Plaetze desselben Modus (Test/Live getrennt). */ export async function countOccupiedSeats(db: Db | Tx, livemode: boolean, now: Date): Promise { const rows = await db.$queryRaw<{ n: number }[]>` SELECT count(*)::int AS n FROM "SeatReservation" WHERE "campId" = ${CAMP_ID} AND "livemode" = ${livemode} AND ("status" IN ('HELD', 'CONVERTED') OR ("status" = 'ACTIVE' AND "expiresAt" > ${now}))`; return rows[0]?.n ?? 0; } export async function lockCamp(tx: Tx): Promise { await tx.$queryRaw`SELECT "id" FROM "Camp" WHERE "id" = ${CAMP_ID} FOR UPDATE`; } export async function createBooking(db: Db, request: BookingRequest, ctx: BookingContext): Promise { const livemode = ctx.mode === "live"; await getCamp(db); // stellt sicher, dass die zu sperrende Zeile existiert return db.$transaction( async (tx) => { // Erst sperren, dann nach dem Idempotenzschluessel suchen: ein parallel // laufender Doppelklick sieht so die bereits festgeschriebene Buchung. await lockCamp(tx); const existing = await tx.booking.findUnique({ where: { idempotencyKey: request.idempotencyKey }, include: { installments: { orderBy: { seq: "asc" } } }, }); if (existing) { if (existing.email !== request.participant.email) return { ok: false, error: { code: "IDEMPOTENCY_CONFLICT" } }; return { ok: true, reused: true, booking: existing }; } const camp = await getCamp(tx); const state = salesStateFor(camp, ctx.mode, ctx.now); const phase = sellablePhase(state); if (!state.checkoutOpen || !phase) return { ok: false, error: { code: "SALES_CLOSED", reason: state.closedReason } }; const occupied = await countOccupiedSeats(tx, livemode, ctx.now); if (occupied >= camp.capacity) return { ok: false, error: { code: "SOLD_OUT" } }; let voucherId: string | null = null; if (request.voucherCode) { const evaluation = await evaluateVoucher(tx, { code: request.voucherCode, email: request.participant.email, now: ctx.now, livemode, quota: camp.stammmodelQuota, lock: true, }); if (!evaluation.ok) return { ok: false, error: { code: "VOUCHER_REJECTED", reason: evaluation.reason } }; voucherId = evaluation.voucher.id; } const price = calculatePrice(phase, voucherId !== null); if (request.planType !== "FULL" && !state.installmentsAllowed) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } }; const schedule = buildSchedule({ planType: request.planType, totalCents: price.totalCents, now: ctx.now, campStartDate: campStartIso(camp), finalInstallmentLeadDays: camp.finalInstallmentLeadDays, minInstallmentGapDays: camp.minInstallmentGapDays, }); if (!schedule.ok) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } }; // Stripe verlangt fuer expires_at mindestens 30 Minuten; eine Minute // Reserve gleicht Uhrabweichungen aus. const priceLockedUntil = new Date(ctx.now.getTime() + (camp.reservationMinutes + 1) * 60_000); const reservationExpiresAt = new Date(priceLockedUntil.getTime() + RESERVATION_GRACE_MINUTES * 60_000); const p = request.participant; const bookingId = crypto.randomUUID(); const booking = await tx.booking.create({ data: { id: bookingId, reference: generateReference(), campId: CAMP_ID, livemode, idempotencyKey: request.idempotencyKey, statusTokenHash: sha256Hex(statusTokenFor(bookingId, ctx.secret)), firstName: p.firstName, lastName: p.lastName, email: p.email, phone: p.phone, country: p.country, notes: p.notes ?? null, adultConfirmedAt: ctx.now, termsAcceptedAt: ctx.now, termsVersion: camp.termsVersion ?? "ENTWURF-NICHT-FREIGEGEBEN", accommodationAcknowledgedAt: ctx.now, privacyAcknowledgedAt: ctx.now, installmentMandateAt: request.planType === "FULL" ? null : ctx.now, pricePhase: price.phase, basePriceCents: price.basePriceCents, phaseDiscountCents: price.phaseDiscountCents, voucherDiscountCents: price.voucherDiscountCents, totalCents: price.totalCents, planType: request.planType as DbPlanType, priceSnapshot: snapshotJson(price, schedule.installments, state, camp.termsVersion), priceLockedUntil, voucherId, installments: { create: schedule.installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueAt: i.dueAt })), }, reservation: { create: { campId: CAMP_ID, livemode, status: "ACTIVE", expiresAt: reservationExpiresAt }, }, ...(voucherId ? { redemption: { create: { voucherId, livemode, status: "RESERVED" } } } : {}), }, include: { installments: { orderBy: { seq: "asc" } } }, }); await audit(tx, CUSTOMER, "booking.created", "Booking", booking.id, { reference: booking.reference, livemode, phase: price.phase, totalCents: price.totalCents, planType: request.planType, voucher: voucherId !== null, seatsOccupiedBefore: occupied, reservationExpiresAt: reservationExpiresAt.toISOString(), }); return { ok: true, reused: false, booking }; }, { timeout: 15_000 }, ); } function snapshotJson(price: PriceBreakdown, installments: ScheduledInstallment[], state: SalesState, termsVersion: string | null) { return { currency: "EUR", phase: price.phase, phaseSimulatedForTest: state.phaseSimulated, basePriceCents: price.basePriceCents, phaseDiscountCents: price.phaseDiscountCents, phasePriceCents: price.phasePriceCents, voucherDiscountCents: price.voucherDiscountCents, totalCents: price.totalCents, installments: installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueDate: i.dueDate })), installmentCount: installments.length, termsVersion, calculatedAt: new Date().toISOString(), }; } /** * Gibt Platz und Gutscheinreservierung einer (noch) nicht bezahlten Buchung * frei. Muss innerhalb einer Transaktion mit gesperrter Buchungszeile laufen. */ export async function releaseBookingHold( tx: Tx, bookingId: string, params: { status: "EXPIRED" | "CANCELED"; reason: string; now: Date }, ): Promise { await tx.seatReservation.updateMany({ where: { bookingId, status: { in: ["ACTIVE", "HELD"] } }, data: { status: "RELEASED", releasedAt: params.now, releaseReason: params.reason }, }); await tx.voucherRedemption.updateMany({ where: { bookingId, status: "RESERVED" }, data: { status: "RELEASED", releasedAt: params.now }, }); await tx.installment.updateMany({ where: { bookingId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } }, data: { status: "CANCELED" }, }); await tx.booking.update({ where: { id: bookingId }, data: params.status === "CANCELED" ? { status: "CANCELED", canceledAt: params.now, cancelReason: params.reason } : { status: "EXPIRED" }, }); } export async function lockBooking(tx: Tx, bookingId: string): Promise { await tx.$queryRaw`SELECT "id" FROM "Booking" WHERE "id" = ${bookingId} FOR UPDATE`; } export function installmentCount(planType: PlanType): number { return INSTALLMENT_COUNT[planType]; }