This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
Reference in new issue
Block a user