Files
Artista-Filmcamp/src/lib/booking-service.ts
T
Nick Szaunig 59bb5a38a3
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s
first commit
2026-09-27 06:06:56 +02:00

262 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<typeof bookingRequestSchema>;
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<number> {
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<void> {
await tx.$queryRaw`SELECT "id" FROM "Camp" WHERE "id" = ${CAMP_ID} FOR UPDATE`;
}
export async function createBooking(db: Db, request: BookingRequest, ctx: BookingContext): Promise<CreateBookingResult> {
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<void> {
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<void> {
await tx.$queryRaw`SELECT "id" FROM "Booking" WHERE "id" = ${bookingId} FOR UPDATE`;
}
export function installmentCount(planType: PlanType): number {
return INSTALLMENT_COUNT[planType];
}