first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
/**
* Serverseitige Zugriffskontrolle fuer den Adminbereich. Wird von JEDER
* Admin-Seite, Server-Action und jedem Admin-Endpunkt aufgerufen — der
* Proxy (src/proxy.ts) leitet nur vorab um und ist keine Sicherheitsgrenze.
*/
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import type { Actor } from "./audit";
import { getAuth } from "./auth";
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
async function currentAdmin(): Promise<AdminUser | null> {
const session = await getAuth().api.getSession({ headers: await headers() });
if (!session) return null;
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
}
/** Fuer Seiten: leitet zur Anmeldung bzw. zur 2FA-Einrichtung um. */
export async function requireAdminPage(options: { allowWithout2fa?: boolean } = {}): Promise<AdminUser> {
const admin = await currentAdmin();
if (!admin) redirect("/admin/anmelden");
if (!admin.twoFactorEnabled && !options.allowWithout2fa) redirect("/admin/sicherheit");
return admin;
}
/** Fuer Server-Actions und Endpunkte: wirft statt umzuleiten. */
export async function requireAdminAction(): Promise<AdminUser> {
const admin = await currentAdmin();
if (!admin || !admin.twoFactorEnabled) throw new Error("Nicht berechtigt.");
return admin;
}
/** Fuer Route-Handler: null statt Ausnahme. */
export async function adminOrNull(): Promise<AdminUser | null> {
const admin = await currentAdmin();
return admin?.twoFactorEnabled ? admin : null;
}
export function adminActor(admin: AdminUser): Actor {
return { type: "ADMIN", id: admin.id };
}
+57
View File
@@ -0,0 +1,57 @@
import type { BookingStatus, PaymentStatus, Prisma } from "@/generated/prisma/client";
const BOOKING_STATUSES: BookingStatus[] = ["PENDING", "CONFIRMED", "CANCELED", "EXPIRED", "NEEDS_REVIEW"];
const PAYMENT_STATUSES: PaymentStatus[] = ["UNPAID", "PROCESSING", "PARTIALLY_PAID", "PAID", "FAILED", "PARTIALLY_REFUNDED", "REFUNDED"];
export type BookingFilter = { status?: BookingStatus; zahlung?: PaymentStatus; modus: "test" | "live"; q?: string };
/** Liest Filter aus der URL; Unbekanntes wird ignoriert statt uebernommen. */
export function parseBookingFilter(params: Record<string, string | string[] | undefined>, defaultMode: "test" | "live"): BookingFilter {
const get = (k: string) => (typeof params[k] === "string" ? (params[k] as string) : undefined);
const status = get("status");
const zahlung = get("zahlung");
const modus = get("modus");
const q = get("q")?.trim().slice(0, 100);
return {
status: BOOKING_STATUSES.includes(status as BookingStatus) ? (status as BookingStatus) : undefined,
zahlung: PAYMENT_STATUSES.includes(zahlung as PaymentStatus) ? (zahlung as PaymentStatus) : undefined,
modus: modus === "live" || modus === "test" ? modus : defaultMode,
q: q || undefined,
};
}
export function bookingWhere(filter: BookingFilter): Prisma.BookingWhereInput {
return {
livemode: filter.modus === "live",
...(filter.status ? { status: filter.status } : {}),
...(filter.zahlung ? { paymentStatus: filter.zahlung } : {}),
...(filter.q
? {
OR: [
{ reference: { contains: filter.q, mode: "insensitive" } },
{ email: { contains: filter.q, mode: "insensitive" } },
{ lastName: { contains: filter.q, mode: "insensitive" } },
{ firstName: { contains: filter.q, mode: "insensitive" } },
],
}
: {}),
};
}
export function filterQuery(filter: BookingFilter): string {
const params = new URLSearchParams();
params.set("modus", filter.modus);
if (filter.status) params.set("status", filter.status);
if (filter.zahlung) params.set("zahlung", filter.zahlung);
if (filter.q) params.set("q", filter.q);
return params.toString();
}
/** Schutz vor Formel-Injektion: Zellen, die mit = + - @ beginnen, werden entschaerft. */
export function csvCell(value: string | number | null | undefined): string {
let s = value === null || value === undefined ? "" : String(value);
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return `"${s.replace(/"/g, '""')}"`;
}
export { BOOKING_STATUSES, PAYMENT_STATUSES };
+247
View File
@@ -0,0 +1,247 @@
/**
* Verwaltungsaktionen. Jede Aktion prueft ihre Eingaben, laeuft unter
* Buchungssperre und wird im AuditLog festgehalten.
*
* Stornierung und Erstattung sind bewusst getrennt: Die freigegebenen
* Stornobedingungen bestimmen, welcher Betrag erstattet wird — diese
* Entscheidung trifft ein Mensch, nicht die Software (docs/BETRIEB.md).
*/
import "./zod-de";
import { z } from "zod";
import { audit, type Actor } from "./audit";
import { countOccupiedSeats, lockBooking, lockCamp } from "./booking-service";
import { CAMP_ID, type Db } from "./db";
import { enqueueEmail } from "./email";
import { bookingConfirmedEmail } from "./email-templates";
import type { PaymentGateway } from "./payment-gateway";
import { getCamp } from "./camp";
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
import { statusTokenFor } from "./tokens";
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
import { detachFinishedPaymentMethods } from "./worker-jobs";
export type ActionResult = { ok: true; message: string } | { ok: false; message: string };
export async function cancelBooking(
db: Db,
gateway: PaymentGateway,
params: { bookingId: string; reason: string },
actor: Actor,
now = new Date(),
): Promise<ActionResult> {
const reason = params.reason.trim().slice(0, 500);
if (!reason) return { ok: false, message: "Bitte einen Grund angeben." };
const before = await db.$transaction(async (tx) => {
await lockBooking(tx, params.bookingId);
const booking = await tx.booking.findUnique({ where: { id: params.bookingId }, include: { installments: true } });
if (!booking || booking.status === "CANCELED") return null;
await tx.installment.updateMany({
where: { bookingId: booking.id, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
data: { status: "CANCELED", nextAttemptAt: null },
});
await tx.seatReservation.updateMany({
where: { bookingId: booking.id, status: { not: "RELEASED" } },
data: { status: "RELEASED", releasedAt: now, releaseReason: "canceled_by_admin" },
});
await tx.voucherRedemption.updateMany({
where: { bookingId: booking.id, status: { not: "RELEASED" } },
data: { status: "RELEASED", releasedAt: now },
});
await tx.booking.update({ where: { id: booking.id }, data: { status: "CANCELED", canceledAt: now, cancelReason: reason } });
await audit(tx, actor, "booking.canceled", "Booking", booking.id, { reason, previousStatus: booking.status, paidCents: booking.paidCents });
return booking;
});
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
// Offene Stripe-Sessions schliessen und gespeicherte Zahlungsmethode
// trennen: nach einer Stornierung darf nichts mehr abgebucht werden.
const sessions = [before.stripeCheckoutSessionId, ...before.installments.map((i) => i.checkoutSessionId)].filter(Boolean) as string[];
for (const id of sessions) await gateway.expireCheckoutSession(id).catch(() => undefined);
await detachFinishedPaymentMethods(db, gateway, [before.id]);
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
}
export async function refundInstallment(
db: Db,
gateway: PaymentGateway,
params: { installmentId: string; amountCents: number; requestKey: string; reason: string },
actor: Actor,
): Promise<ActionResult> {
if (!Number.isSafeInteger(params.amountCents) || params.amountCents <= 0) return { ok: false, message: "Ungültiger Betrag." };
const installment = await db.installment.findUnique({ where: { id: params.installmentId }, include: { refunds: true } });
if (!installment || installment.status !== "PAID" || !installment.stripePaymentIntentId) {
return { ok: false, message: "Nur bezahlte Raten können erstattet werden." };
}
const alreadyRequested = installment.refunds.filter((r) => r.status !== "failed" && r.status !== "canceled").reduce((s, r) => s + r.amountCents, 0);
const refundable = installment.amountCents - Math.max(alreadyRequested, installment.refundedCents);
if (params.amountCents > refundable) return { ok: false, message: `Höchstens ${(refundable / 100).toFixed(2)} € erstattbar.` };
const refund = await gateway.createRefund(
{ paymentIntentId: installment.stripePaymentIntentId, amountCents: params.amountCents, metadata: { bookingId: installment.bookingId, installmentId: installment.id } },
`refund:${params.requestKey}`,
);
await db.refund.upsert({
where: { stripeRefundId: refund.id },
create: {
bookingId: installment.bookingId,
installmentId: installment.id,
stripeRefundId: refund.id,
amountCents: refund.amount,
status: refund.status,
reason: params.reason.slice(0, 500) || null,
createdBy: actor.id ?? null,
},
update: { status: refund.status },
});
await audit(db, actor, "payment.refund_requested", "Installment", installment.id, {
bookingId: installment.bookingId,
refundId: refund.id,
amountCents: refund.amount,
});
return { ok: true, message: "Erstattung bei Stripe ausgelöst. Der Zahlungsstatus aktualisiert sich, sobald Stripe sie bestätigt." };
}
/** Entscheidung ueber eine Buchung im Status NEEDS_REVIEW. */
export async function confirmReviewedBooking(db: Db, bookingId: string, actor: Actor, now = new Date()): Promise<ActionResult> {
return db.$transaction(async (tx) => {
await lockBooking(tx, bookingId);
const booking = await tx.booking.findUnique({ where: { id: bookingId }, include: { reservation: true } });
if (!booking || booking.status !== "NEEDS_REVIEW") return { ok: false, message: "Buchung ist nicht in Prüfung." };
if (booking.paidCents <= 0) return { ok: false, message: "Ohne Zahlungseingang kann nicht bestätigt werden." };
if (!booking.reservation) return { ok: false, message: "Zu dieser Buchung gibt es keine Platzreservierung." };
if (booking.reservation.status !== "CONVERTED") {
await lockCamp(tx);
const camp = await getCamp(tx);
const occupied = await countOccupiedSeats(tx, booking.livemode, now);
if (occupied >= camp.capacity) {
return { ok: false, message: "Kein Platz frei. Kapazität in den Einstellungen erhöhen oder die Buchung stornieren." };
}
await tx.seatReservation.update({ where: { bookingId }, data: { status: "CONVERTED", releasedAt: null, releaseReason: null } });
}
await tx.booking.update({ where: { id: bookingId }, data: { status: "CONFIRMED", confirmedAt: now, reviewReason: null } });
await audit(tx, actor, "booking.review_confirmed", "Booking", bookingId, { previousReason: booking.reviewReason });
return { ok: true, message: "Buchung bestätigt." };
});
}
export async function resendConfirmation(db: Db, bookingId: string, secret: string, actor: Actor): Promise<ActionResult> {
const booking = await db.booking.findUnique({ where: { id: bookingId }, include: { installments: { orderBy: { seq: "asc" } } } });
if (!booking || booking.status !== "CONFIRMED") return { ok: false, message: "Nur bestätigte Buchungen." };
const mail = bookingConfirmedEmail(booking, statusTokenFor(booking.id, secret));
await enqueueEmail(db, { ...mail, dedupeKey: `${mail.dedupeKey}:resend:${Date.now()}` });
await audit(db, actor, "booking.confirmation_resent", "Booking", bookingId);
return { ok: true, message: "Bestätigung erneut in den Postausgang gelegt." };
}
// ---------------------------------------------------------------------------
// Gutscheine
// ---------------------------------------------------------------------------
const dateOnly = z
.string()
.trim()
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), "Datum im Format JJJJ-MM-TT");
/** "Gueltig ab" = 00:00 Uhr Berlin des Tages. */
const validFromDate = dateOnly.transform((v) => (v ? berlinStartOfDay(v) : null));
/** "Gueltig bis einschliesslich" = exklusive Grenze 00:00 Uhr Berlin am Folgetag. */
const validUntilDate = dateOnly.transform((v) => (v ? berlinStartOfDay(addDays(v, 1)) : null));
export const voucherInputSchema = z
.object({
code: z.string().transform((v, ctx) => {
const code = normalizeVoucherCode(v);
if (!code) {
ctx.addIssue({ code: "custom", message: "Code: 4–40 Zeichen, nur Buchstaben, Ziffern und Bindestrich." });
return z.NEVER;
}
return code;
}),
active: z.boolean(),
validFrom: validFromDate,
validUntil: validUntilDate,
maxRedemptions: z.coerce.number().int().min(1).max(100),
boundEmail: z
.string()
.trim()
.transform((v) => (v ? normalizeEmail(v) : null))
.refine((v) => v === null || z.email().safeParse(v).success, "Ungültige E-Mail-Adresse"),
internalNote: z.string().trim().max(500).transform((v) => v || null),
})
.refine((v) => !v.validFrom || !v.validUntil || v.validFrom < v.validUntil, { message: "Gültig bis muss nach Gültig ab liegen.", path: ["validUntil"] });
export type VoucherInput = z.infer<typeof voucherInputSchema>;
export async function saveVoucher(db: Db, id: string | null, input: VoucherInput, actor: Actor): Promise<ActionResult & { id?: string }> {
const clash = await db.voucher.findUnique({ where: { code: input.code } });
if (clash && clash.id !== id) return { ok: false, message: "Dieser Code existiert bereits." };
const voucher = id ? await db.voucher.update({ where: { id }, data: input }) : await db.voucher.create({ data: input });
await audit(db, actor, id ? "voucher.updated" : "voucher.created", "Voucher", voucher.id, {
active: input.active,
maxRedemptions: input.maxRedemptions,
bound: input.boundEmail !== null,
});
return { ok: true, message: id ? "Gutschein gespeichert." : "Gutschein angelegt.", id: voucher.id };
}
// ---------------------------------------------------------------------------
// Camp-Konfiguration
// ---------------------------------------------------------------------------
const text = (max: number) => z.string().trim().max(max).transform((v) => v || null);
const isoDate = z
.string()
.trim()
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), "Datum im Format JJJJ-MM-TT")
.transform((v) => (v ? isoToDbDate(v) : null));
export const campInputSchema = z.object({
salesEnabled: z.boolean(),
capacity: z.coerce.number().int().min(0).max(200),
capacityConfirmed: z.boolean(),
stammmodelQuota: z.coerce.number().int().min(0).max(200),
stammmodelQuotaConfirmed: z.boolean(),
campStartDate: isoDate,
campEndDate: isoDate,
dateConfirmed: z.boolean(),
locationName: text(200),
locationRegion: text(200),
locationConfirmed: z.boolean(),
accommodationText: text(3000),
accommodationConfirmed: z.boolean(),
servicesConfirmed: z.boolean(),
organizerName: text(200),
organizerAddress: text(500),
organizerEmail: text(200).refine((v) => v === null || z.email().safeParse(v).success, "Ungültige E-Mail-Adresse"),
organizerPhone: text(60),
organizerConfirmed: z.boolean(),
taxNote: text(500),
taxConfirmed: z.boolean(),
termsVersion: text(60),
termsUrl: text(500).refine((v) => v === null || v.startsWith("/") || v.startsWith("https://"), "Nur https:// oder ein Pfad dieser Seite"),
termsApproved: z.boolean(),
travelLawChecked: z.boolean(),
installmentsApproved: z.boolean(),
installmentRuleApproved: z.boolean(),
reservationMinutes: z.coerce.number().int().min(30).max(120),
finalInstallmentLeadDays: z.coerce.number().int().min(0).max(120),
minInstallmentGapDays: z.coerce.number().int().min(1).max(90),
testPhaseOverride: z.enum(["", "EARLY_BIRD", "REGULAR"]).transform((v) => (v ? v : null)),
});
export type CampInput = z.infer<typeof campInputSchema>;
export async function updateCamp(db: Db, input: CampInput, actor: Actor): Promise<ActionResult> {
if (input.campStartDate && input.campEndDate && input.campEndDate < input.campStartDate) {
return { ok: false, message: "Das Enddatum liegt vor dem Anreisetag." };
}
const before = await getCamp(db);
const changed = (Object.keys(input) as (keyof CampInput)[]).filter((key) => {
const a = before[key];
const b = input[key];
return (a instanceof Date ? a.getTime() : a) !== (b instanceof Date ? b.getTime() : b);
});
await db.camp.update({ where: { id: CAMP_ID }, data: { ...input, updatedBy: actor.id ?? null } });
await audit(db, actor, "camp.updated", "Camp", CAMP_ID, { changed });
return { ok: true, message: changed.length ? `Gespeichert (${changed.length} Änderungen).` : "Keine Änderungen." };
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Gemeinsame Bausteine fuer die oeffentlichen API-Routen: einheitliche
* Antwortform, Schutz schreibender Endpunkte, Rate Limiting.
*/
import "./zod-de";
import { NextResponse } from "next/server";
import type { z } from "zod";
import { getDb } from "./db";
import { env, requireAppSecret } from "./env";
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
const MAX_BODY_BYTES = 16 * 1024;
export function ok<T>(data: T, status = 200): NextResponse {
return NextResponse.json({ success: true, data }, { status, headers: { "Cache-Control": "no-store" } });
}
export function fail(status: number, error: ApiError, headers: Record<string, string> = {}): NextResponse {
return NextResponse.json({ success: false, error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
}
/**
* CSRF-Schutz: schreibende Aufrufe nur von der eigenen Seite (Origin bzw.
* Sec-Fetch-Site) und nur mit JSON — ein fremdes HTML-Formular kann weder
* das eine noch das andere liefern.
*/
export function checkSameOrigin(request: Request): NextResponse | null {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.startsWith("application/json")) {
return fail(415, { code: "UNSUPPORTED_MEDIA_TYPE", message: "Anfrage muss JSON sein." });
}
const site = request.headers.get("sec-fetch-site");
const origin = request.headers.get("origin");
const forbidden = fail(403, { code: "FORBIDDEN", message: "Anfrage nicht erlaubt." });
// Fail closed: ohne jede Herkunftsangabe keine Annahme.
if (!site && !origin) return forbidden;
if (site && site !== "same-origin") return forbidden;
if (origin && origin !== new URL(env().APP_URL).origin) return forbidden;
return null;
}
export async function readJson<T extends z.ZodType>(
request: Request,
schema: T,
): Promise<{ ok: true; data: z.infer<T> } | { ok: false; response: NextResponse }> {
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) {
return { ok: false, response: fail(413, { code: "TOO_LARGE", message: "Anfrage ist zu groß." }) };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, response: fail(400, { code: "INVALID_JSON", message: "Anfrage konnte nicht gelesen werden." }) };
}
const result = schema.safeParse(parsed);
if (!result.success) {
const fields: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".");
if (!fields[key]) fields[key] = issue.message;
}
return {
ok: false,
response: fail(422, { code: "VALIDATION", message: "Bitte prüfe die markierten Angaben.", fields }),
};
}
return { ok: true, data: result.data };
}
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
if (result.allowed) return null;
return fail(
429,
{ code: "RATE_LIMITED", message: "Zu viele Anfragen. Bitte warte einen Moment und versuche es dann erneut." },
{ "Retry-After": String(result.retryAfterSeconds) },
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ActorType, Prisma } from "@/generated/prisma/client";
import type { Db, Tx } from "./db";
export type Actor = { type: ActorType; id?: string | null };
export const SYSTEM: Actor = { type: "SYSTEM" };
export const STRIPE_ACTOR: Actor = { type: "STRIPE" };
export const WORKER: Actor = { type: "WORKER" };
export const CUSTOMER: Actor = { type: "CUSTOMER" };
/**
* Protokolliert Buchungs-, Bestands- und Zahlungsaktionen. "details" enthaelt
* Betraege, Status und Stripe-IDs — keine Kontaktdaten der Teilnehmerin.
*/
export async function audit(
db: Db | Tx,
actor: Actor,
action: string,
entityType: string,
entityId: string,
details?: Prisma.InputJsonValue,
): Promise<void> {
await db.auditLog.create({
data: { actorType: actor.type, actorId: actor.id ?? null, action, entityType, entityId, details },
});
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { twoFactorClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
// Den zweiten Anmeldeschritt steuert LoginForm selbst (twoFactorRedirect).
plugins: [twoFactorClient()],
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Anmeldung fuer den Adminbereich mit Better Auth (etablierte Loesung statt
* Eigenbau).
*
* - Keine Selbstregistrierung: Konten entstehen nur per
* `pnpm admin:create` (scripts/create-admin.ts), ohne Standardpasswort.
* - Zwei-Faktor-Anmeldung (TOTP) ist Pflicht: admin-auth.ts laesst ohne
* aktivierte 2FA nur die Einrichtungsseite zu.
* - Rate Limits liegen in PostgreSQL und gelten damit ueber alle Replikate.
* - Passwoerter werden gegen bekannte Datenlecks geprueft (k-Anonymitaet,
* nur ein Hash-Praefix verlaesst den Server).
*/
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
import { twoFactor } from "better-auth/plugins/two-factor";
import { getDb } from "./db";
import { env } from "./env";
function createAuth(options: { allowSignUp?: boolean } = {}) {
const e = env();
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret || secret.length < 32) throw new Error("BETTER_AUTH_SECRET fehlt oder ist kuerzer als 32 Zeichen.");
return betterAuth({
appName: "ARTISTA Modelcamp Verwaltung",
baseURL: e.APP_URL,
secret,
database: prismaAdapter(getDb(), { provider: "postgresql" }),
emailAndPassword: {
enabled: true,
disableSignUp: !options.allowSignUp,
minPasswordLength: 12,
maxPasswordLength: 128,
},
session: {
expiresIn: 60 * 60 * 8,
updateAge: 60 * 30,
},
rateLimit: {
enabled: true,
storage: "database",
modelName: "rateLimit",
window: 60,
max: 60,
customRules: {
"/sign-in/email": { window: 300, max: 5 },
"/two-factor/verify-totp": { window: 300, max: 5 },
"/two-factor/verify-backup-code": { window: 300, max: 5 },
},
},
advanced: {
useSecureCookies: e.APP_URL.startsWith("https://"),
ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] },
},
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
});
}
type Auth = ReturnType<typeof createAuth>;
let instance: Auth | null = null;
/**
* Nur fuer scripts/create-admin.ts (Kommandozeile im Cluster): eine eigene
* Instanz, die Registrierung erlaubt. Der Webserver nutzt sie nie — dort
* bleibt disableSignUp aktiv.
*/
export function createProvisioningAuth(): Auth {
return createAuth({ allowSignUp: true });
}
/** Lazy, damit der Build ohne Datenbank und Secrets auskommt. */
export function getAuth(): Auth {
if (!instance) instance = createAuth();
return instance;
}
+261
View File
@@ -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];
}
+87
View File
@@ -0,0 +1,87 @@
import type { Camp } from "@/generated/prisma/client";
import { CAMP_ID, type Db, type Tx } from "./db";
import { evaluateSales, type CampGateFields, type SalesState, type StripeMode } from "./sales-gate";
import { dbDateToIso } from "./time";
/**
* Laedt die Camp-Konfiguration. Fehlt die Zeile (frische Datenbank), wird
* sie mit sicheren Standardwerten angelegt: Live-Verkauf aus, keine
* Freigaben, 10 Plaetze und 4 Stammmodel-Buchungen als vorlaeufige Werte.
*/
export async function getCamp(db: Db | Tx): Promise<Camp> {
return db.camp.upsert({ where: { id: CAMP_ID }, create: { id: CAMP_ID }, update: {} });
}
export function campStartIso(camp: Pick<Camp, "campStartDate">): string | null {
return camp.campStartDate ? dbDateToIso(camp.campStartDate) : null;
}
export function toGateFields(camp: Camp): CampGateFields {
return {
salesEnabled: camp.salesEnabled,
capacityConfirmed: camp.capacityConfirmed,
stammmodelQuotaConfirmed: camp.stammmodelQuotaConfirmed,
campStartDate: campStartIso(camp),
dateConfirmed: camp.dateConfirmed,
locationName: camp.locationName,
locationConfirmed: camp.locationConfirmed,
accommodationText: camp.accommodationText,
accommodationConfirmed: camp.accommodationConfirmed,
servicesConfirmed: camp.servicesConfirmed,
organizerName: camp.organizerName,
organizerAddress: camp.organizerAddress,
organizerEmail: camp.organizerEmail,
organizerConfirmed: camp.organizerConfirmed,
taxNote: camp.taxNote,
taxConfirmed: camp.taxConfirmed,
termsVersion: camp.termsVersion,
termsApproved: camp.termsApproved,
travelLawChecked: camp.travelLawChecked,
installmentsApproved: camp.installmentsApproved,
installmentRuleApproved: camp.installmentRuleApproved,
testPhaseOverride: camp.testPhaseOverride,
};
}
export function salesStateFor(camp: Camp, mode: StripeMode, now: Date = new Date()): SalesState {
return evaluateSales(now, toGateFields(camp), mode);
}
/**
* Oeffentlich anzeigbare, bestaetigte Fakten. Unbestaetigte Werte werden
* hier bewusst zu null — die Seite zeigt dann "wird bekanntgegeben" statt
* eines ungeprueften Datums oder Orts.
*/
export type PublicCampFacts = {
startDate: string | null;
endDate: string | null;
location: string | null;
region: string | null;
accommodationText: string | null;
taxNote: string | null;
organizer: { name: string; address: string; email: string; phone: string | null } | null;
termsUrl: string | null;
termsVersion: string | null;
};
export function publicCampFacts(camp: Camp): PublicCampFacts {
return {
startDate: camp.dateConfirmed ? campStartIso(camp) : null,
endDate: camp.dateConfirmed && camp.campEndDate ? dbDateToIso(camp.campEndDate) : null,
location: camp.locationConfirmed ? camp.locationName : null,
region: camp.locationConfirmed ? camp.locationRegion : null,
accommodationText: camp.accommodationConfirmed ? camp.accommodationText : null,
taxNote: camp.taxConfirmed ? camp.taxNote : null,
organizer:
camp.organizerConfirmed && camp.organizerName && camp.organizerAddress && camp.organizerEmail
? {
name: camp.organizerName,
address: camp.organizerAddress,
email: camp.organizerEmail,
phone: camp.organizerPhone,
}
: null,
termsUrl: camp.termsApproved ? camp.termsUrl : null,
termsVersion: camp.termsApproved ? camp.termsVersion : null,
};
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Buchungsstrecke bis zur Stripe-Zahlungsseite.
*
* Preisbindung (Briefing Abschnitt 5): Massgeblich ist der Zeitpunkt, zu dem
* die Buchung gestartet wird. Der dabei berechnete Preis gilt, wenn die
* Zahlung innerhalb der Reservierungszeit (Standard 30 Minuten) abgeschlossen
* wird. Stripe erzwingt das ueber expires_at der Checkout-Session: danach ist
* sie nicht mehr bezahlbar. Ein neuer Buchungsstart wird zum dann gueltigen
* Preis berechnet — auch ueber den Jahreswechsel hinweg: Wer am 31.12. um
* 23:50 startet, zahlt den Early-Bird-Preis, sofern die Zahlung bis zum
* angezeigten Ablauf gelingt; eine Verlaengerung gibt es nicht.
*/
import type { Booking, Installment } from "@/generated/prisma/client";
import { audit, CUSTOMER } from "./audit";
import { createBooking, lockBooking, releaseBookingHold, type BookingError, type BookingRequest } from "./booking-service";
import type { Db } from "./db";
import { appUrl } from "./env";
import { formatEuro } from "./money";
import type { PaymentGateway } from "./payment-gateway";
import { statusTokenFor } from "./tokens";
import { formatDate, formatDateTime } from "./time";
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027 – Teilnahme";
export const PRODUCT_DESCRIPTION = "Teilnahme pro Person mit geteilter Unterbringung";
/** Stripe akzeptiert expires_at erst ab 30 Minuten in der Zukunft. */
const MIN_STRIPE_SESSION_MS = 30 * 60_000;
export type CheckoutError =
| BookingError
| { code: "PAYMENT_PROVIDER" }
| { code: "ATTEMPT_EXPIRED" }
| { code: "ALREADY_COMPLETED"; statusUrl: string };
export type CheckoutResult = { ok: true; url: string; reference: string } | { ok: false; error: CheckoutError };
export function planSummary(installments: Pick<Installment, "seq" | "amountCents" | "dueAt">[], totalCents: number): string {
const sorted = [...installments].sort((a, b) => a.seq - b.seq);
const today = `Heute fällig: ${formatEuro(sorted[0]!.amountCents)}.`;
const later = sorted
.slice(1)
.map((i) => `${formatEuro(i.amountCents)} am ${i.dueAt ? formatDate(i.dueAt) : "–"}`)
.join(", ");
return [
`Gesamtpreis ${formatEuro(totalCents)}.`,
today,
later ? `Weitere Raten: ${later} – automatisch über die hier gespeicherte Zahlungsmethode.` : "",
]
.filter(Boolean)
.join(" ");
}
export async function startCheckout(
db: Db,
gateway: PaymentGateway,
request: BookingRequest,
ctx: { now: Date; secret: string },
): Promise<CheckoutResult> {
const created = await createBooking(db, request, { now: ctx.now, mode: gateway.mode, secret: ctx.secret });
if (!created.ok) return { ok: false, error: created.error };
const booking = created.booking;
const token = statusTokenFor(booking.id, ctx.secret);
if (created.reused) {
if (booking.status !== "PENDING") {
return { ok: false, error: { code: "ALREADY_COMPLETED", statusUrl: appUrl(`/buchung/status/${token}`) } };
}
if (booking.stripeCheckoutUrl && booking.priceLockedUntil > ctx.now) {
return { ok: true, url: booking.stripeCheckoutUrl, reference: booking.reference };
}
}
if (booking.priceLockedUntil.getTime() - ctx.now.getTime() < MIN_STRIPE_SESSION_MS - 60_000) {
return { ok: false, error: { code: "ATTEMPT_EXPIRED" } };
}
try {
// Idempotenzschluessel je Buchung: ein wiederholter Aufruf (Doppelklick,
// Netzabbruch) erzeugt bei Stripe keinen zweiten Kunden und keine zweite
// Session.
const customer = await gateway.createCustomer(
{
email: booking.email,
name: `${booking.firstName} ${booking.lastName}`,
metadata: { bookingId: booking.id, reference: booking.reference },
},
`customer:${booking.id}`,
);
const first = booking.installments.find((i) => i.seq === 1)!;
const count = booking.installments.length;
const session = await gateway.createCheckoutSession(
{
customerId: customer.id,
amountCents: first.amountCents,
productName: PRODUCT_NAME,
description: count > 1 ? `${PRODUCT_DESCRIPTION} · Rate 1 von ${count}` : PRODUCT_DESCRIPTION,
metadata: { kind: "booking", bookingId: booking.id, reference: booking.reference, installmentId: first.id },
saveForFutureUsage: count > 1,
expiresAt: booking.priceLockedUntil,
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
cancelUrl: appUrl(`/buchung/status/${token}?checkout=cancel`),
submitMessage: `${planSummary(booking.installments, booking.totalCents)} Preis gebunden bis ${formatDateTime(booking.priceLockedUntil)}.`,
},
`checkout:${booking.id}`,
);
await db.booking.update({
where: { id: booking.id },
data: { stripeCustomerId: customer.id, stripeCheckoutSessionId: session.id, stripeCheckoutUrl: session.url },
});
await audit(db, CUSTOMER, "checkout.session_created", "Booking", booking.id, { sessionId: session.id });
return { ok: true, url: session.url, reference: booking.reference };
} catch (error) {
console.error(`[checkout] Stripe-Fehler fuer Buchung ${booking.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
await db.$transaction(async (tx) => {
await lockBooking(tx, booking.id);
const current = await tx.booking.findUniqueOrThrow({ where: { id: booking.id } });
if (current.status === "PENDING" && current.paymentStatus === "UNPAID" && !current.stripeCheckoutSessionId) {
await releaseBookingHold(tx, booking.id, { status: "EXPIRED", reason: "payment_provider_error", now: ctx.now });
}
});
return { ok: false, error: { code: "PAYMENT_PROVIDER" } };
}
}
export type InstallmentCheckoutResult =
| { ok: true; url: string }
| { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
/**
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug, bei
* verlangter Authentifizierung oder freiwillig vorzeitig). Die Rate wird
* fuer die Laufzeit der Session markiert, damit der Worker sie nicht
* gleichzeitig off-session einzieht.
*/
export async function startInstallmentCheckout(
db: Db,
gateway: PaymentGateway,
params: { bookingId: string; installmentId: string },
ctx: { now: Date; secret: string },
): Promise<InstallmentCheckoutResult> {
const sessionExpiresAt = new Date(ctx.now.getTime() + MIN_STRIPE_SESSION_MS + 60_000);
const claim = await db.$transaction(async (tx) => {
await lockBooking(tx, params.bookingId);
const booking = await tx.booking.findUniqueOrThrow({
where: { id: params.bookingId },
include: { installments: true },
});
const installment = booking.installments.find((i) => i.id === params.installmentId);
if (!installment || installment.seq === 1) return null;
if (booking.status !== "CONFIRMED" || !booking.stripeCustomerId) return null;
if (!["PENDING", "FAILED", "REQUIRES_ACTION"].includes(installment.status)) return null;
if (booking.paidCents + installment.amountCents > booking.totalCents) return null;
if (installment.checkoutSessionId && installment.checkoutExpiresAt && installment.checkoutExpiresAt > new Date(ctx.now.getTime() + 5 * 60_000)) {
return { booking, installment, existingSessionId: installment.checkoutSessionId };
}
await tx.installment.update({
where: { id: installment.id },
data: { checkoutExpiresAt: sessionExpiresAt, checkoutSessionId: null },
});
return { booking, installment, existingSessionId: null as string | null };
});
if (!claim) return { ok: false, error: "NOT_PAYABLE" };
const { booking, installment } = claim;
try {
if (claim.existingSessionId) {
const existing = await gateway.retrieveCheckoutSession(claim.existingSessionId);
if (existing.status === "open" && existing.url) return { ok: true, url: existing.url };
}
const token = statusTokenFor(booking.id, ctx.secret);
const session = await gateway.createCheckoutSession(
{
customerId: booking.stripeCustomerId!,
amountCents: installment.amountCents,
productName: PRODUCT_NAME,
description: `${PRODUCT_DESCRIPTION} · Rate ${installment.seq} von ${booking.installments.length}`,
metadata: { kind: "installment", bookingId: booking.id, reference: booking.reference, installmentId: installment.id },
saveForFutureUsage: booking.installments.some((i) => i.seq > installment.seq && i.status !== "PAID"),
expiresAt: sessionExpiresAt,
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
cancelUrl: appUrl(`/buchung/status/${token}`),
submitMessage: `Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für Buchung ${booking.reference}.`,
},
`installment-checkout:${installment.id}:${sessionExpiresAt.getTime()}`,
);
await db.installment.update({ where: { id: installment.id }, data: { checkoutSessionId: session.id } });
await audit(db, CUSTOMER, "checkout.installment_session_created", "Installment", installment.id, { sessionId: session.id });
return { ok: true, url: session.url };
} catch (error) {
console.error(`[checkout] Stripe-Fehler fuer Rate ${installment.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
await db.installment.updateMany({
where: { id: installment.id, checkoutSessionId: null },
data: { checkoutExpiresAt: null },
});
return { ok: false, error: "PAYMENT_PROVIDER" };
}
}
/** Kundin verwirft eine begonnene, unbezahlte Buchung selbst. */
export async function abandonCheckout(
db: Db,
gateway: PaymentGateway,
booking: Pick<Booking, "id" | "stripeCheckoutSessionId">,
ctx: { now: Date },
): Promise<void> {
if (booking.stripeCheckoutSessionId) {
await gateway.expireCheckoutSession(booking.stripeCheckoutSessionId);
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId);
// Wurde doch noch bezahlt, entscheidet der Webhook — hier nichts freigeben.
if (session.status === "complete") return;
}
await db.$transaction(async (tx) => {
await lockBooking(tx, booking.id);
const current = await tx.booking.findUniqueOrThrow({ where: { id: booking.id } });
if (current.status !== "PENDING" || current.paymentStatus !== "UNPAID") return;
await releaseBookingHold(tx, booking.id, { status: "EXPIRED", reason: "abandoned_by_customer", now: ctx.now });
await audit(tx, CUSTOMER, "booking.abandoned", "Booking", booking.id);
});
}
+23
View File
@@ -0,0 +1,23 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
export type Db = PrismaClient;
/** Transaktions-Client innerhalb von db.$transaction(async (tx) => …). */
export type Tx = Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0];
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
function createClient(): PrismaClient {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
const adapter = new PrismaPg({ connectionString, max: Number(process.env.DATABASE_POOL_SIZE ?? 10) });
return new PrismaClient({ adapter, log: ["error"] });
}
/** Lazy, damit der Build (ohne Datenbank) Module importieren kann. */
export function getDb(): PrismaClient {
if (!globalForPrisma.prisma) globalForPrisma.prisma = createClient();
return globalForPrisma.prisma;
}
export const CAMP_ID = "modelcamp-2027";
+165
View File
@@ -0,0 +1,165 @@
/**
* Texte der Buchungs-E-Mails. Jede Mail nennt nur den tatsaechlich
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
*/
import type { Booking, Installment } from "@/generated/prisma/client";
import { escapeHtml, type EmailDraft } from "./email";
import { appUrl } from "./env";
import { formatEuro } from "./money";
import { PLAN_LABEL } from "./payment-plan";
import { PHASE_LABEL } from "./pricing";
import { formatDate } from "./time";
type BookingWithInstallments = Booking & { installments: Installment[] };
const BRAND = "ARTISTA MODELCAMP 2027";
function statusLink(token: string): string {
return appUrl(`/buchung/status/${token}`);
}
function planLines(booking: BookingWithInstallments): string[] {
return booking.installments
.slice()
.sort((a, b) => a.seq - b.seq)
.map((i) => {
const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung";
const state =
i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen";
return `Rate ${i.seq}: ${formatEuro(i.amountCents)} – ${due} – ${state}`;
});
}
function wrap(subject: string, paragraphs: string[], link?: { href: string; label: string }): { text: string; html: string } {
const text = [...paragraphs, ...(link ? [`${link.label}: ${link.href}`] : []), "", `${BRAND}`].join("\n\n");
const html = `<!doctype html><html lang="de"><body style="font-family:Georgia,serif;color:#1f1d1b;background:#f6f1ea;padding:24px">
<div style="max-width:560px;margin:0 auto;background:#fffdf9;padding:32px;border:1px solid #e6ddd0">
<p style="letter-spacing:.2em;font-size:12px;text-transform:uppercase;color:#8a5a3b">${BRAND}</p>
<h1 style="font-weight:normal;font-size:24px">${escapeHtml(subject)}</h1>
${paragraphs.map((p) => `<p style="font-family:Arial,sans-serif;font-size:15px;line-height:1.6;white-space:pre-line">${escapeHtml(p)}</p>`).join("\n")}
${link ? `<p><a href="${escapeHtml(link.href)}" style="display:inline-block;background:#1f1d1b;color:#fffdf9;padding:12px 20px;text-decoration:none;font-family:Arial,sans-serif">${escapeHtml(link.label)}</a></p>` : ""}
</div></body></html>`;
return { text, html };
}
function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft {
return { dedupeKey: key, to: booking.email, subject: `${subject} – ${BRAND}`, bookingId: booking.id, ...body };
}
export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt – erste Rate erhalten";
const lines = [
`Hallo ${booking.firstName},`,
`vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`,
`Gesamtpreis: ${formatEuro(booking.totalCents)} (${PHASE_LABEL[booking.pricePhase]}${booking.voucherDiscountCents > 0 ? ", inkl. Gutschein" : ""})\nZahlungsweise: ${PLAN_LABEL[booking.planType]}\nBezahlt: ${formatEuro(booking.paidCents)}\nOffen: ${formatEuro(Math.max(0, booking.totalCents - booking.paidCents))}`,
planLines(booking).join("\n"),
];
if (!fullyPaid) {
lines.push("Die weiteren Raten werden zu den genannten Terminen über die hinterlegte Zahlungsmethode eingezogen. Du erhältst jeweils eine Bestätigung.");
}
lines.push("Alle Details und den aktuellen Zahlungsstand findest du jederzeit auf deiner Buchungsseite.");
return draft(`booking-confirmed:${booking.id}`, booking, subject, wrap(subject, lines, { href: statusLink(token), label: "Buchung ansehen" }));
}
export function paymentProcessingEmail(booking: Booking, token: string): EmailDraft {
const subject = "Deine Zahlung wird verarbeitet";
return draft(
`payment-processing:${booking.id}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
`wir haben deine Buchung ${booking.reference} erhalten. Deine Zahlungsart braucht einige Tage, bis die Zahlung bestätigt ist. Dein Platz bleibt so lange reserviert.`,
"Sobald die Zahlung eingegangen ist, bekommst du eine Bestätigung. Erst dann ist die Buchung verbindlich bestätigt.",
],
{ href: statusLink(token), label: "Status ansehen" },
),
);
}
export function paymentFailedEmail(booking: Booking): EmailDraft {
const subject = "Deine Zahlung ist fehlgeschlagen";
return draft(
`payment-failed:${booking.id}`,
booking,
subject,
wrap(subject, [
`Hallo ${booking.firstName},`,
`leider konnte die Zahlung für deine Buchung ${booking.reference} nicht abgeschlossen werden. Die Reservierung wurde deshalb aufgehoben.`,
"Du kannst jederzeit eine neue Buchung starten.",
]),
);
}
export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Vollständig bezahlt – danke!" : `Rate ${installment.seq} erhalten`;
return draft(
`installment-paid:${installment.id}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
`wir haben Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für deine Buchung ${booking.reference} erhalten.`,
fullyPaid
? "Damit ist dein Platz vollständig bezahlt. Weitere Abbuchungen finden nicht statt."
: `Bezahlt: ${formatEuro(booking.paidCents)} · Offen: ${formatEuro(booking.totalCents - booking.paidCents)}`,
planLines(booking).join("\n"),
],
{ href: statusLink(token), label: "Buchung ansehen" },
),
);
}
export function installmentActionEmail(booking: Booking, installment: Installment, token: string, requiresAction: boolean): EmailDraft {
const subject = requiresAction ? `Bitte bestätige Rate ${installment.seq}` : `Rate ${installment.seq} konnte nicht eingezogen werden`;
return draft(
`installment-action:${installment.id}:${installment.attemptCount}:${requiresAction ? "auth" : "fail"}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
requiresAction
? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.`
: `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`,
"Du kannst die Rate über den folgenden Link selbst begleichen – auch mit einer anderen Zahlungsmethode.",
],
{ href: statusLink(token), label: "Rate jetzt bezahlen" },
),
);
}
export function adminReviewEmail(to: string, booking: Booking, reason: string): EmailDraft {
const subject = `Prüfung nötig: Buchung ${booking.reference}`;
const body = wrap(subject, [
`Die Buchung ${booking.reference} benötigt eine manuelle Entscheidung.`,
`Grund: ${reason}`,
"Details stehen im Adminbereich unter Buchungen.",
], { href: appUrl(`/admin/buchungen/${booking.id}`), label: "Im Adminbereich öffnen" });
return { dedupeKey: `admin-review:${booking.id}:${reason}`, to, subject, bookingId: booking.id, ...body };
}
export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft {
const subject = "Deine Zahlung ist eingegangen – wir melden uns";
return draft(
`review-customer:${booking.id}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
`deine Zahlung für die Buchung ${booking.reference} ist bei uns eingegangen. Bevor wir die Buchung bestätigen können, müssen wir sie kurz manuell prüfen.`,
"Wir melden uns umgehend bei dir. Solltest du keinen Platz erhalten, wird dir der gezahlte Betrag vollständig erstattet.",
],
{ href: statusLink(token), label: "Status ansehen" },
),
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* E-Mail-Versand ueber einen transaktionalen Postausgang.
*
* enqueueEmail() schreibt in derselben Datenbanktransaktion wie die
* Statusaenderung — eine bestaetigte Buchung ohne Bestaetigungsmail kann so
* nicht entstehen. Versendet wird danach (flushEmails) und, falls das
* scheitert, erneut durch den Worker.
*
* MAIL_TRANSPORT=file (Standard in der Entwicklung) schreibt jede Mail als
* .eml-Datei nach MAIL_OUTBOX_DIR statt sie zu verschicken: ein sicherer
* Testausgang, der nie echte Postfaecher erreicht.
*/
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import nodemailer, { type Transporter } from "nodemailer";
import type { Db, Tx } from "./db";
import { env, isProduction } from "./env";
export type EmailDraft = {
dedupeKey: string;
to: string;
subject: string;
text: string;
html: string;
bookingId?: string | null;
};
export const MAX_EMAIL_ATTEMPTS = 8;
export async function enqueueEmail(db: Db | Tx, draft: EmailDraft): Promise<void> {
await db.emailMessage.createMany({
data: [
{
dedupeKey: draft.dedupeKey,
toAddress: draft.to,
subject: draft.subject,
textBody: draft.text,
htmlBody: draft.html,
bookingId: draft.bookingId ?? null,
},
],
skipDuplicates: true,
});
}
let transporter: Transporter | null = null;
function smtpTransport(): Transporter {
if (!transporter) {
const e = env();
if (!e.SMTP_HOST) throw new Error("SMTP_HOST ist nicht gesetzt.");
transporter = nodemailer.createTransport({
host: e.SMTP_HOST,
port: e.SMTP_PORT,
secure: e.SMTP_SECURE === "true",
auth: e.SMTP_USER ? { user: e.SMTP_USER, pass: e.SMTP_PASSWORD ?? "" } : undefined,
});
}
return transporter;
}
async function deliver(message: { id: string; toAddress: string; subject: string; textBody: string; htmlBody: string }) {
const e = env();
if (e.MAIL_TRANSPORT === "file") {
if (isProduction()) throw new Error("MAIL_TRANSPORT=file ist im Produktivbetrieb nicht erlaubt.");
const dir = path.resolve(e.MAIL_OUTBOX_DIR);
await mkdir(dir, { recursive: true });
const stream = nodemailer.createTransport({ streamTransport: true, buffer: true, newline: "unix" });
const info = await stream.sendMail({
from: e.MAIL_FROM,
to: message.toAddress,
subject: message.subject,
text: message.textBody,
html: message.htmlBody,
});
await writeFile(path.join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${message.id}.eml`), info.message as Buffer);
return;
}
await smtpTransport().sendMail({
from: e.MAIL_FROM,
to: message.toAddress,
subject: message.subject,
text: message.textBody,
html: message.htmlBody,
});
}
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
const pending = await db.emailMessage.findMany({
where: {
status: "PENDING",
attempts: { lt: MAX_EMAIL_ATTEMPTS },
...(params.bookingId ? { bookingId: params.bookingId } : {}),
},
orderBy: { createdAt: "asc" },
take: params.limit ?? 50,
});
let sent = 0;
let failed = 0;
for (const message of pending) {
// Beanspruchen, damit parallele Replikate dieselbe Mail nicht doppelt senden.
const claimed = await db.emailMessage.updateMany({
where: { id: message.id, status: "PENDING", attempts: message.attempts },
data: { attempts: { increment: 1 } },
});
if (claimed.count === 0) continue;
try {
await deliver(message);
await db.emailMessage.update({ where: { id: message.id }, data: { status: "SENT", sentAt: new Date(), lastError: null } });
sent++;
} catch (error) {
failed++;
const reason = error instanceof Error ? error.message.slice(0, 300) : "unbekannter Fehler";
await db.emailMessage.update({
where: { id: message.id },
data: {
lastError: reason,
status: message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
},
});
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}): ${reason}`);
}
}
return { sent, failed };
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Zentrale, validierte Konfiguration aus Umgebungsvariablen.
* Vorlage mit Erklaerungen: .env.example
*/
import { z } from "zod";
import type { StripeMode } from "./sales-gate";
const schema = z.object({
APP_ENV: z.enum(["development", "staging", "production"]).default("development"),
APP_URL: z.url().default("http://localhost:3000"),
APP_SECRET: z.string().min(32).optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
MAIL_TRANSPORT: z.enum(["smtp", "file"]).default("file"),
MAIL_OUTBOX_DIR: z.string().default(".mail-outbox"),
MAIL_FROM: z.string().default("ARTISTA MODELCAMP <no-reply@localhost>"),
ADMIN_NOTIFY_EMAIL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().default(587),
SMTP_SECURE: z.enum(["true", "false"]).default("false"),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
MEDIA_DIR: z.string().default("./media"),
/** Datenschutzkonzept fuer externe Videos: "none" laedt nie externe Player. */
EXTERNAL_VIDEO_PROVIDER: z.enum(["none", "youtube-nocookie", "vimeo"]).default("none"),
});
export type Env = z.infer<typeof schema>;
let cached: Env | null = null;
export function env(): Env {
if (!cached) {
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
// Nur Feldnamen ausgeben, nie Werte (koennten Geheimnisse sein).
const fields = parsed.error.issues.map((i) => i.path.join(".")).join(", ");
throw new Error(`Ungueltige Konfiguration: ${fields}`);
}
cached = parsed.data;
}
return cached;
}
/** Nur fuer Tests: Konfiguration nach Aenderung von process.env neu lesen. */
export function resetEnvCache(): void {
cached = null;
}
export function isProduction(): boolean {
return env().APP_ENV === "production";
}
/** Entwicklungs-Platzhalter (leere Video-Slots usw.) sichtbar? */
export function showDevPlaceholders(): boolean {
return env().APP_ENV !== "production";
}
export function appUrl(path = "/"): string {
return new URL(path, env().APP_URL).toString();
}
export function requireAppSecret(): string {
const secret = env().APP_SECRET;
if (!secret) throw new Error("APP_SECRET ist nicht gesetzt (mindestens 32 Zeichen).");
return secret;
}
/**
* Modus aus dem Stripe-Schluessel. Ein Live-Schluessel ausserhalb von
* APP_ENV=production ist ein Konfigurationsfehler: so kann eine Entwicklungs-
* oder Staging-Umgebung nie versehentlich echtes Geld einziehen.
*/
export function stripeMode(): StripeMode {
const key = env().STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
if (/^(sk|rk)_live_/.test(key)) {
if (!isProduction()) throw new Error("Stripe-Live-Schluessel ist nur mit APP_ENV=production erlaubt.");
return "live";
}
if (/^(sk|rk)_test_/.test(key)) return "test";
throw new Error("STRIPE_SECRET_KEY hat kein bekanntes Format.");
}
/** Wie stripeMode(), aber ohne Ausnahme (fuer reine Anzeige). */
export function stripeModeOrNull(): StripeMode | null {
try {
return stripeMode();
} catch {
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { BookingStatus, InstallmentStatus, PaymentStatus, ReservationStatus, RedemptionStatus } from "@/generated/prisma/client";
export const BOOKING_STATUS_LABEL: Record<BookingStatus, string> = {
PENDING: "Reserviert, Zahlung ausstehend",
CONFIRMED: "Bestätigt",
CANCELED: "Storniert",
EXPIRED: "Verfallen",
NEEDS_REVIEW: "In Prüfung",
};
export const PAYMENT_STATUS_LABEL: Record<PaymentStatus, string> = {
UNPAID: "Unbezahlt",
PROCESSING: "Zahlung in Bearbeitung",
PARTIALLY_PAID: "Teilweise bezahlt",
PAID: "Vollständig bezahlt",
FAILED: "Zahlung fehlgeschlagen",
PARTIALLY_REFUNDED: "Teilweise erstattet",
REFUNDED: "Erstattet",
};
export const INSTALLMENT_STATUS_LABEL: Record<InstallmentStatus, string> = {
PENDING: "Offen",
PROCESSING: "In Bearbeitung",
PAID: "Bezahlt",
FAILED: "Fehlgeschlagen",
REQUIRES_ACTION: "Deine Bestätigung nötig",
CANCELED: "Storniert",
};
export const RESERVATION_STATUS_LABEL: Record<ReservationStatus, string> = {
ACTIVE: "Reserviert (befristet)",
HELD: "Gehalten (Zahlung unterwegs)",
CONVERTED: "Belegt",
RELEASED: "Freigegeben",
};
export const REDEMPTION_STATUS_LABEL: Record<RedemptionStatus, string> = {
RESERVED: "Reserviert",
COMPLETED: "Eingelöst",
RELEASED: "Freigegeben",
};
+29
View File
@@ -0,0 +1,29 @@
/** Alle Betraege sind ganzzahlige Cent in EUR. */
const eur = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/** 220000 -> "2.200,00 €" */
export function formatEuro(cents: number): string {
assertCents(cents);
// Intl setzt ein geschuetztes Leerzeichen vor das Eurozeichen; fuer
// E-Mails/CSV ist ein normales Leerzeichen robuster.
return eur.format(cents / 100).replace(/ /g, " ");
}
/** 220000 -> "2.200 €" (nur fuer glatte Betraege in Ueberschriften). */
export function formatEuroShort(cents: number): string {
assertCents(cents);
if (cents % 100 !== 0) return formatEuro(cents);
return `${new Intl.NumberFormat("de-DE").format(cents / 100)} €`;
}
export function assertCents(cents: number): void {
if (!Number.isSafeInteger(cents)) {
throw new Error(`Betrag muss ganzzahlige Cent sein, erhalten: ${cents}`);
}
}
+379
View File
@@ -0,0 +1,379 @@
/**
* Zustandsuebergaenge fuer Zahlungen.
*
* Grundsaetze:
* - Jede Funktion laeuft in einer Transaktion und sperrt zuerst die
* Buchungszeile. Doppelte, verspaetete oder ungeordnete Stripe-Events
* treffen dadurch immer auf einen konsistenten Stand.
* - Uebergaenge sind monoton: Eine bezahlte Rate wird nie wieder offen, ein
* spaetes "expired" hebt keine bezahlte Buchung auf.
* - Buchungsstatus (BookingStatus) und Zahlungsstatus (PaymentStatus) sind
* getrennt; eine bezahlte erste Rate heisst bei Ratenzahlung PARTIALLY_PAID.
* - Was nicht automatisch sicher entschieden werden kann (verspaetete Zahlung
* ohne freien Platz, Doppelzahlung, Rueckbuchung), landet als
* NEEDS_REVIEW mit Begruendung im Adminbereich — nie still verworfen.
*/
import type { Booking, Installment, PaymentStatus } from "@/generated/prisma/client";
import { audit, type Actor } from "./audit";
import { countOccupiedSeats, lockBooking, lockCamp, releaseBookingHold } from "./booking-service";
import { getCamp } from "./camp";
import type { Tx } from "./db";
import { enqueueEmail } from "./email";
import {
adminReviewEmail,
bookingConfirmedEmail,
installmentActionEmail,
installmentPaidEmail,
paymentFailedEmail,
paymentProcessingEmail,
reviewCustomerEmail,
} from "./email-templates";
import { statusTokenFor } from "./tokens";
import { countUsedRedemptions } from "./vouchers";
export const MAX_OFF_SESSION_ATTEMPTS = 3;
export const RETRY_DELAY_DAYS = 3;
export type EventContext = { now: Date; secret: string; actor: Actor; adminEmail: string | null };
export type PaymentInfo = {
paymentIntentId: string;
chargeId: string | null;
paymentMethodId: string | null;
customerId: string | null;
amountCents: number;
};
type FullBooking = Booking & { installments: Installment[] };
async function loadLocked(tx: Tx, bookingId: string) {
await lockBooking(tx, bookingId);
return tx.booking.findUniqueOrThrow({
where: { id: bookingId },
include: { installments: { orderBy: { seq: "asc" } }, reservation: true, redemption: true },
});
}
function paymentStatusFor(paidCents: number, totalCents: number, refundedCents: number): PaymentStatus {
if (refundedCents > 0) return refundedCents >= paidCents ? "REFUNDED" : "PARTIALLY_REFUNDED";
if (paidCents <= 0) return "UNPAID";
return paidCents >= totalCents ? "PAID" : "PARTIALLY_PAID";
}
async function flagForReview(tx: Tx, booking: Booking, reason: string, ctx: EventContext): Promise<void> {
await tx.booking.update({ where: { id: booking.id }, data: { status: "NEEDS_REVIEW", reviewReason: reason } });
await audit(tx, ctx.actor, "booking.needs_review", "Booking", booking.id, { reason });
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, booking, reason));
}
export type FirstPaymentOutcome = "confirmed" | "duplicate" | "needs_review";
/** Erste Rate (bzw. Einmalzahlung) ist bei Stripe erfolgreich eingegangen. */
export async function applyFirstPaymentSucceeded(
tx: Tx,
bookingId: string,
info: PaymentInfo,
ctx: EventContext,
): Promise<FirstPaymentOutcome> {
const booking = await loadLocked(tx, bookingId);
const first = booking.installments.find((i) => i.seq === 1)!;
if (first.status === "PAID") {
if (first.stripePaymentIntentId && first.stripePaymentIntentId !== info.paymentIntentId) {
await flagForReview(tx, booking, "Doppelzahlung der ersten Rate – bitte eine Zahlung erstatten.", ctx);
return "needs_review";
}
return "duplicate";
}
const problems: string[] = [];
if (info.amountCents !== first.amountCents) {
problems.push(`Betrag weicht ab (erwartet ${first.amountCents}, erhalten ${info.amountCents} Cent).`);
}
// Platz: normalerweise noch reserviert. Wurde er inzwischen freigegeben
// (Zahlung kam nach Ablauf an), wird er nur uebernommen, wenn noch frei.
const reservation = booking.reservation!;
if (reservation.status === "ACTIVE" || reservation.status === "HELD") {
await tx.seatReservation.update({ where: { id: reservation.id }, data: { status: "CONVERTED" } });
} else if (reservation.status === "RELEASED") {
await lockCamp(tx);
const camp = await getCamp(tx);
const occupied = await countOccupiedSeats(tx, booking.livemode, ctx.now);
if (occupied < camp.capacity) {
await tx.seatReservation.update({
where: { id: reservation.id },
data: { status: "CONVERTED", releasedAt: null, releaseReason: null },
});
await audit(tx, ctx.actor, "seat.reacquired_after_late_payment", "Booking", booking.id, { occupiedBefore: occupied });
} else {
problems.push("Zahlung nach Ablauf der Reservierung eingegangen, inzwischen ist kein Platz mehr frei.");
}
}
const redemption = booking.redemption;
if (redemption?.status === "RESERVED") {
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED" } });
} else if (redemption?.status === "RELEASED") {
await tx.$queryRaw`SELECT "id" FROM "Voucher" WHERE "id" = ${redemption.voucherId} FOR UPDATE`;
const [voucher, camp] = await Promise.all([
tx.voucher.findUniqueOrThrow({ where: { id: redemption.voucherId } }),
getCamp(tx),
]);
const used = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now, voucherId: voucher.id });
const quotaUsed = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now });
if (used < voucher.maxRedemptions && quotaUsed < camp.stammmodelQuota) {
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED", releasedAt: null } });
} else {
problems.push("Gutscheincode war bei verspäteter Zahlung nicht mehr verfügbar.");
}
}
await tx.installment.update({
where: { id: first.id },
data: {
status: "PAID",
paidAt: ctx.now,
stripePaymentIntentId: info.paymentIntentId,
stripeChargeId: info.chargeId,
},
});
const paidCents = booking.paidCents + info.amountCents;
const updated = await tx.booking.update({
where: { id: booking.id },
data: {
status: problems.length > 0 ? "NEEDS_REVIEW" : "CONFIRMED",
reviewReason: problems.length > 0 ? problems.join(" ") : null,
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
paidCents,
confirmedAt: problems.length > 0 ? null : ctx.now,
stripeCustomerId: info.customerId ?? booking.stripeCustomerId,
// Nur bei Ratenzahlung gespeichert; bei Einmalzahlung gibt es nichts
// wiederzuverwenden.
stripePaymentMethodId: booking.planType === "FULL" ? null : info.paymentMethodId,
},
include: { installments: { orderBy: { seq: "asc" } } },
});
await audit(tx, ctx.actor, "payment.first_succeeded", "Booking", booking.id, {
paymentIntentId: info.paymentIntentId,
amountCents: info.amountCents,
paidCents,
previousStatus: booking.status,
problems,
});
const token = statusTokenFor(booking.id, ctx.secret);
if (problems.length > 0) {
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, updated, problems.join(" ")));
await enqueueEmail(tx, reviewCustomerEmail(updated, token));
return "needs_review";
}
await enqueueEmail(tx, bookingConfirmedEmail(updated, token));
return "confirmed";
}
/** Checkout abgeschlossen, Zahlung aber noch unterwegs (z. B. SEPA-Lastschrift). */
export async function applyFirstPaymentProcessing(
tx: Tx,
bookingId: string,
info: { paymentIntentId: string | null; customerId: string | null },
ctx: EventContext,
): Promise<"processing" | "ignored"> {
const booking = await loadLocked(tx, bookingId);
const first = booking.installments.find((i) => i.seq === 1)!;
if (booking.status !== "PENDING" || first.status !== "PENDING") return "ignored";
await tx.seatReservation.updateMany({ where: { bookingId, status: "ACTIVE" }, data: { status: "HELD" } });
await tx.installment.update({
where: { id: first.id },
data: { status: "PROCESSING", stripePaymentIntentId: info.paymentIntentId },
});
const updated = await tx.booking.update({
where: { id: bookingId },
data: { paymentStatus: "PROCESSING", stripeCustomerId: info.customerId ?? booking.stripeCustomerId },
});
await audit(tx, ctx.actor, "payment.first_processing", "Booking", bookingId, { paymentIntentId: info.paymentIntentId });
await enqueueEmail(tx, paymentProcessingEmail(updated, statusTokenFor(bookingId, ctx.secret)));
return "processing";
}
/** Verzoegerte erste Zahlung endgueltig fehlgeschlagen: Platz freigeben. */
export async function applyFirstPaymentFailed(tx: Tx, bookingId: string, ctx: EventContext): Promise<"failed" | "ignored"> {
const booking = await loadLocked(tx, bookingId);
const first = booking.installments.find((i) => i.seq === 1)!;
if (first.status === "PAID" || booking.status === "CANCELED") return "ignored";
await tx.installment.update({ where: { id: first.id }, data: { status: "FAILED", failureCode: "async_payment_failed" } });
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: "payment_failed", now: ctx.now });
const updated = await tx.booking.update({ where: { id: bookingId }, data: { paymentStatus: "FAILED" } });
await audit(tx, ctx.actor, "payment.first_failed", "Booking", bookingId);
await enqueueEmail(tx, paymentFailedEmail(updated));
return "failed";
}
/** Checkout-Session abgelaufen oder abgebrochen: nur unbezahlte Buchungen freigeben. */
export async function applyCheckoutExpired(
tx: Tx,
bookingId: string,
sessionId: string | null,
ctx: EventContext & { reason?: string },
): Promise<"released" | "ignored"> {
const booking = await loadLocked(tx, bookingId);
if (sessionId && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== sessionId) return "ignored";
if (booking.status !== "PENDING" || booking.paymentStatus !== "UNPAID") return "ignored";
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: ctx.reason ?? "checkout_expired", now: ctx.now });
await audit(tx, ctx.actor, "booking.expired", "Booking", bookingId, { sessionId, reason: ctx.reason ?? "checkout_expired" });
return "released";
}
export type InstallmentPaidOutcome = { outcome: "paid" | "duplicate" | "needs_review"; fullyPaid: boolean };
/** Folgerate bezahlt (Off-Session-Einzug oder selbst bezahlt per Link). */
export async function applyInstallmentPaid(
tx: Tx,
installmentId: string,
info: PaymentInfo,
ctx: EventContext,
): Promise<InstallmentPaidOutcome> {
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
const booking = await loadLocked(tx, target.bookingId);
const installment = booking.installments.find((i) => i.id === installmentId)!;
if (installment.status === "PAID") {
if (installment.stripePaymentIntentId !== info.paymentIntentId) {
await flagForReview(tx, booking, `Rate ${installment.seq} wurde doppelt bezahlt – bitte eine Zahlung erstatten.`, ctx);
return { outcome: "needs_review", fullyPaid: booking.paidCents >= booking.totalCents };
}
return { outcome: "duplicate", fullyPaid: booking.paidCents >= booking.totalCents };
}
const paidCents = booking.paidCents + info.amountCents;
await tx.installment.update({
where: { id: installmentId },
data: {
status: "PAID",
paidAt: ctx.now,
stripePaymentIntentId: info.paymentIntentId,
stripeChargeId: info.chargeId,
nextAttemptAt: null,
failureCode: null,
},
});
const updated = await tx.booking.update({
where: { id: booking.id },
data: {
paidCents,
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
...(info.paymentMethodId && booking.planType !== "FULL" && !booking.paymentMethodDetachedAt
? { stripePaymentMethodId: info.paymentMethodId }
: {}),
},
include: { installments: { orderBy: { seq: "asc" } } },
});
await audit(tx, ctx.actor, "payment.installment_succeeded", "Installment", installmentId, {
bookingId: booking.id,
seq: installment.seq,
paymentIntentId: info.paymentIntentId,
amountCents: info.amountCents,
paidCents,
});
const problems: string[] = [];
if (info.amountCents !== installment.amountCents) problems.push(`Betrag von Rate ${installment.seq} weicht ab.`);
if (installment.status === "CANCELED" || booking.status === "CANCELED") problems.push(`Rate ${installment.seq} ging nach Stornierung ein.`);
if (paidCents > booking.totalCents) problems.push("Es wurde mehr als der Gesamtpreis gezahlt.");
if (problems.length > 0) {
await flagForReview(tx, updated, problems.join(" "), ctx);
return { outcome: "needs_review", fullyPaid: paidCents >= booking.totalCents };
}
await enqueueEmail(tx, installmentPaidEmail(updated, { ...installment, status: "PAID" }, statusTokenFor(booking.id, ctx.secret)));
return { outcome: "paid", fullyPaid: paidCents >= booking.totalCents };
}
/**
* Off-Session-Einzug fehlgeschlagen. Greift nur, solange die Rate vom Worker
* beansprucht ist (PROCESSING) — so zaehlt ein Fehler nie doppelt, egal ob
* ihn der Worker direkt oder der Webhook zuerst meldet.
*/
export async function applyInstallmentFailed(
tx: Tx,
installmentId: string,
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
ctx: EventContext,
): Promise<"recorded" | "ignored"> {
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
const booking = await loadLocked(tx, target.bookingId);
const installment = booking.installments.find((i) => i.id === installmentId)!;
if (installment.status !== "PROCESSING") return "ignored";
const attempt = installment.attemptCount + 1;
const retry = !info.requiresAction && attempt < MAX_OFF_SESSION_ATTEMPTS;
const updatedInstallment = await tx.installment.update({
where: { id: installmentId },
data: {
status: info.requiresAction ? "REQUIRES_ACTION" : "FAILED",
attemptCount: attempt,
failureCode: info.failureCode,
stripePaymentIntentId: info.paymentIntentId ?? installment.stripePaymentIntentId,
nextAttemptAt: retry ? new Date(ctx.now.getTime() + RETRY_DELAY_DAYS * 86_400_000) : null,
},
});
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installmentId, {
bookingId: booking.id,
seq: installment.seq,
attempt,
failureCode: info.failureCode,
requiresAction: info.requiresAction,
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
});
await enqueueEmail(
tx,
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
);
if (!retry && !info.requiresAction) {
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${attempt} Versuchen nicht eingegangen.`, ctx);
}
return "recorded";
}
/**
* Erstattungsstand einer Zahlung. Stripe liefert den kumulierten Betrag
* (amount_refunded) — absolute Werte machen ungeordnete Events harmlos.
*/
export async function applyRefundState(
tx: Tx,
params: { paymentIntentId: string; amountRefundedCents: number },
ctx: EventContext,
): Promise<"updated" | "ignored"> {
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: params.paymentIntentId } });
if (!target) return "ignored";
const booking = await loadLocked(tx, target.bookingId);
await tx.installment.update({ where: { id: target.id }, data: { refundedCents: params.amountRefundedCents } });
const refundedCents = booking.installments.reduce(
(sum, i) => sum + (i.id === target.id ? params.amountRefundedCents : i.refundedCents),
0,
);
await tx.booking.update({
where: { id: booking.id },
data: { refundedCents, paymentStatus: paymentStatusFor(booking.paidCents, booking.totalCents, refundedCents) },
});
await audit(tx, ctx.actor, "payment.refund_state", "Booking", booking.id, {
paymentIntentId: params.paymentIntentId,
installmentRefundedCents: params.amountRefundedCents,
bookingRefundedCents: refundedCents,
});
return "updated";
}
export async function applyDispute(tx: Tx, paymentIntentId: string, ctx: EventContext): Promise<"flagged" | "ignored"> {
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: paymentIntentId } });
if (!target) return "ignored";
const booking = await loadLocked(tx, target.bookingId);
await flagForReview(tx, booking, `Rückbuchung (Dispute) zu Rate ${target.seq} eröffnet.`, ctx);
return "flagged";
}
export type { FullBooking };
+264
View File
@@ -0,0 +1,264 @@
/**
* Schmale Schnittstelle zu Stripe.
*
* Die Geschaeftslogik spricht nur mit PaymentGateway; die Tests setzen eine
* Attrappe ein (tests/support/fake-gateway.ts), der Betrieb die Stripe-
* Implementierung unten. Kartendaten beruehren diese Anwendung nie — Stripe
* Checkout erfasst sie, hier liegen nur Objekt-IDs.
*
* Architekturentscheidung Ratenzahlung (docs/ARCHITEKTUR.md, ADR-3):
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session,
* Folgeraten als einzelne Off-Session-PaymentIntents, ausgeloest durch einen
* idempotenten Kubernetes-CronJob. Kein Abonnement, kein Subscription
* Schedule: deren Intervalle passen nicht zur Regel "letzte Rate 30 Tage vor
* Anreise", und die Planlaenge ist hier durch die Ratenzeilen selbst
* begrenzt.
*/
import Stripe from "stripe";
import { env, stripeMode } from "./env";
import type { StripeMode } from "./sales-gate";
export type CheckoutSessionInfo = {
id: string;
status: "open" | "complete" | "expired";
paymentStatus: "paid" | "unpaid" | "no_payment_required";
paymentIntentId: string | null;
customerId: string | null;
url: string | null;
metadata: Record<string, string>;
};
export type PaymentIntentInfo = {
id: string;
status: string;
amountReceived: number;
paymentMethodId: string | null;
chargeId: string | null;
metadata: Record<string, string>;
};
export type OffSessionResult = {
outcome: "succeeded" | "processing" | "requires_action" | "failed";
paymentIntentId: string | null;
chargeId: string | null;
failureCode: string | null;
};
export type CreateCheckoutParams = {
customerId: string;
amountCents: number;
productName: string;
description: string;
metadata: Record<string, string>;
/** Zahlungsmethode fuer spaetere Raten speichern. */
saveForFutureUsage: boolean;
expiresAt: Date;
successUrl: string;
cancelUrl: string;
submitMessage: string;
};
export interface PaymentGateway {
readonly mode: StripeMode;
createCustomer(params: { email: string; name: string; metadata: Record<string, string> }, idempotencyKey: string): Promise<{ id: string }>;
createCheckoutSession(params: CreateCheckoutParams, idempotencyKey: string): Promise<{ id: string; url: string }>;
retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo>;
expireCheckoutSession(id: string): Promise<void>;
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
chargeOffSession(
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
idempotencyKey: string,
): Promise<OffSessionResult>;
detachPaymentMethod(id: string): Promise<void>;
createRefund(
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
idempotencyKey: string,
): Promise<{ id: string; status: string; amount: number }>;
}
/** Unbekannte, kuenftige Werte gelten vorsichtshalber als unbezahlt. */
export function normalizePaymentStatus(value: string): CheckoutSessionInfo["paymentStatus"] {
return value === "paid" ? "paid" : value === "no_payment_required" ? "no_payment_required" : "unpaid";
}
function idOf(value: string | { id: string } | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
export class StripeGateway implements PaymentGateway {
readonly mode: StripeMode;
private readonly stripe: Stripe;
constructor(secretKey: string, mode: StripeMode) {
this.mode = mode;
this.stripe = new Stripe(secretKey, { maxNetworkRetries: 2, timeout: 20_000 });
}
async createCustomer(params: { email: string; name: string; metadata: Record<string, string> }, idempotencyKey: string) {
const customer = await this.stripe.customers.create(
{ email: params.email, name: params.name, metadata: params.metadata, preferred_locales: ["de"] },
{ idempotencyKey },
);
return { id: customer.id };
}
async createCheckoutSession(params: CreateCheckoutParams, idempotencyKey: string) {
const session = await this.stripe.checkout.sessions.create(
{
mode: "payment",
customer: params.customerId,
client_reference_id: params.metadata.bookingId,
locale: "de",
line_items: [
{
quantity: 1,
price_data: {
currency: "eur",
unit_amount: params.amountCents,
product_data: { name: params.productName, description: params.description },
},
},
],
payment_intent_data: {
description: params.description,
metadata: params.metadata,
...(params.saveForFutureUsage ? { setup_future_usage: "off_session" as const } : {}),
},
metadata: params.metadata,
// Preisbindung: die Session laeuft genau mit der Reservierung ab und
// kann danach nicht mehr bezahlt werden.
expires_at: Math.floor(params.expiresAt.getTime() / 1000),
success_url: params.successUrl,
cancel_url: params.cancelUrl,
custom_text: { submit: { message: params.submitMessage } },
},
{ idempotencyKey },
);
if (!session.url) throw new Error("Stripe hat keine Checkout-URL geliefert.");
return { id: session.id, url: session.url };
}
async retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo> {
const s = await this.stripe.checkout.sessions.retrieve(id);
return {
id: s.id,
status: (s.status ?? "open") as CheckoutSessionInfo["status"],
paymentStatus: normalizePaymentStatus(s.payment_status),
paymentIntentId: idOf(s.payment_intent),
customerId: idOf(s.customer),
url: s.url ?? null,
metadata: (s.metadata ?? {}) as Record<string, string>,
};
}
async expireCheckoutSession(id: string): Promise<void> {
try {
await this.stripe.checkout.sessions.expire(id);
} catch (error) {
// Bereits abgelaufen oder abgeschlossen: kein Fehler fuer uns, der
// Aufrufer prueft den Zustand anschliessend erneut.
if (!(error instanceof Stripe.errors.StripeInvalidRequestError)) throw error;
}
}
async retrievePaymentIntent(id: string): Promise<PaymentIntentInfo> {
const pi = await this.stripe.paymentIntents.retrieve(id);
return {
id: pi.id,
status: pi.status,
amountReceived: pi.amount_received,
paymentMethodId: idOf(pi.payment_method),
chargeId: idOf(pi.latest_charge),
metadata: (pi.metadata ?? {}) as Record<string, string>,
};
}
async chargeOffSession(
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
idempotencyKey: string,
): Promise<OffSessionResult> {
try {
const pi = await this.stripe.paymentIntents.create(
{
amount: params.amountCents,
currency: "eur",
customer: params.customerId,
payment_method: params.paymentMethodId,
off_session: true,
confirm: true,
description: params.description,
metadata: params.metadata,
},
{ idempotencyKey },
);
return {
outcome:
pi.status === "succeeded"
? "succeeded"
: pi.status === "processing"
? "processing"
: pi.status === "requires_action"
? "requires_action"
: "failed",
paymentIntentId: pi.id,
chargeId: idOf(pi.latest_charge),
failureCode: pi.last_payment_error?.code ?? null,
};
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
return {
outcome: error.code === "authentication_required" ? "requires_action" : "failed",
paymentIntentId: error.payment_intent?.id ?? null,
chargeId: null,
failureCode: error.decline_code ?? error.code ?? "card_error",
};
}
throw error;
}
}
async detachPaymentMethod(id: string): Promise<void> {
try {
await this.stripe.paymentMethods.detach(id);
} catch (error) {
// Bereits getrennt -> Ziel erreicht.
if (!(error instanceof Stripe.errors.StripeInvalidRequestError)) throw error;
}
}
async createRefund(
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
idempotencyKey: string,
) {
const refund = await this.stripe.refunds.create(
{ payment_intent: params.paymentIntentId, amount: params.amountCents, metadata: params.metadata },
{ idempotencyKey },
);
return { id: refund.id, status: refund.status ?? "pending", amount: refund.amount };
}
}
let gateway: PaymentGateway | null = null;
export function getGateway(): PaymentGateway {
if (!gateway) {
const key = env().STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
gateway = new StripeGateway(key, stripeMode());
}
return gateway;
}
/** Nur fuer Tests. */
export function setGatewayForTests(fake: PaymentGateway | null): void {
gateway = fake;
}
/**
* Signaturpruefung gegen den UNVERAENDERTEN Request-Body. Braucht keinen
* API-Schluessel, nur das Webhook-Secret.
*/
export function verifyStripeWebhook(rawBody: string, signature: string, secret: string): Stripe.Event {
return Stripe.webhooks.constructEvent(rawBody, signature, secret);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Zahlungsplaene (Briefing Abschnitt 7).
*
* Aufteilung: Laesst sich der Gesamtpreis ohne Centrest gleichmaessig
* teilen, sind alle Raten gleich (z. B. 1.237,50 € + 1.237,50 €). Sonst
* werden die Folgeraten auf volle 50 € abgerundet und der Rest faellt in die
* erste Rate (z. B. 800 € + 700 € + 700 €). Das reproduziert exakt die
* Vorgabetabelle; tests/payment-plan.test.ts prueft alle zwoelf Faelle.
*
* Faelligkeiten: Erste Rate bei Buchung. Die letzte Rate ist am Stichtag
* faellig = Anreise minus finalInstallmentLeadDays (Standard 30 Tage). Bei
* drei Raten liegt die zweite in der Mitte zwischen Buchungstag und
* Stichtag. Eingezogen wird am Faelligkeitstag um 09:00 Uhr Berlin. Diese
* Terminregel ist ein technischer Vorschlag und muss vor dem Live-Verkauf
* freigegeben werden (Camp.installmentRuleApproved).
*/
import { addDays, berlinDateString, berlinStartOfDay, daysBetween } from "./time";
export type PlanType = "FULL" | "TWO_INSTALLMENTS" | "THREE_INSTALLMENTS";
export const PLAN_TYPES: readonly PlanType[] = ["FULL", "TWO_INSTALLMENTS", "THREE_INSTALLMENTS"];
export const INSTALLMENT_COUNT: Record<PlanType, number> = {
FULL: 1,
TWO_INSTALLMENTS: 2,
THREE_INSTALLMENTS: 3,
};
export const PLAN_LABEL: Record<PlanType, string> = {
FULL: "Einmalzahlung",
TWO_INSTALLMENTS: "Zwei Raten",
THREE_INSTALLMENTS: "Drei Raten",
};
const ROUNDING_STEP_CENTS = 5_000;
export const CHARGE_HOUR_BERLIN = 9;
export function splitIntoInstallments(totalCents: number, count: number): number[] {
if (!Number.isSafeInteger(totalCents) || totalCents <= 0) throw new Error("Ungueltiger Gesamtbetrag");
if (count < 1 || count > 3) throw new Error("Maximal drei Raten");
if (count === 1) return [totalCents];
if (totalCents % count === 0) return Array.from({ length: count }, () => totalCents / count);
const later = Math.floor(totalCents / count / ROUNDING_STEP_CENTS) * ROUNDING_STEP_CENTS;
const first = totalCents - later * (count - 1);
return [first, ...Array.from({ length: count - 1 }, () => later)];
}
export type ScheduledInstallment = { seq: number; amountCents: number; dueAt: Date | null; dueDate: string | null };
export type ScheduleUnavailableReason = "CAMP_DATE_MISSING" | "TOO_LATE" | "NOT_APPROVED";
export type ScheduleResult =
| { ok: true; installments: ScheduledInstallment[] }
| { ok: false; reason: ScheduleUnavailableReason };
export type ScheduleInput = {
planType: PlanType;
totalCents: number;
now: Date;
/** Anreisetag YYYY-MM-DD oder null, solange unbekannt. */
campStartDate: string | null;
finalInstallmentLeadDays: number;
minInstallmentGapDays: number;
};
export function buildSchedule(input: ScheduleInput): ScheduleResult {
const count = INSTALLMENT_COUNT[input.planType];
const amounts = splitIntoInstallments(input.totalCents, count);
if (count === 1) {
return { ok: true, installments: [{ seq: 1, amountCents: amounts[0]!, dueAt: null, dueDate: null }] };
}
if (!input.campStartDate) return { ok: false, reason: "CAMP_DATE_MISSING" };
const today = berlinDateString(input.now);
const deadline = addDays(input.campStartDate, -input.finalInstallmentLeadDays);
const available = daysBetween(today, deadline);
// Jede Folgerate braucht mindestens minInstallmentGapDays Abstand zur
// vorherigen — sonst ist die Variante serverseitig nicht waehlbar.
if (available < input.minInstallmentGapDays * (count - 1)) return { ok: false, reason: "TOO_LATE" };
const dueDates: string[] =
count === 2 ? [deadline] : [addDays(today, Math.floor(available / 2)), deadline];
return {
ok: true,
installments: amounts.map((amountCents, index) => {
if (index === 0) return { seq: 1, amountCents, dueAt: null, dueDate: null };
const dueDate = dueDates[index - 1]!;
return { seq: index + 1, amountCents, dueDate, dueAt: berlinStartOfDay(dueDate, CHARGE_HOUR_BERLIN) };
}),
};
}
export const SCHEDULE_REASON_TEXT: Record<ScheduleUnavailableReason, string> = {
CAMP_DATE_MISSING: "Ratenzahlung ist verfügbar, sobald der Camp-Termin feststeht.",
TOO_LATE: "Für eine Ratenzahlung bleibt bis zur letzten Frist nicht mehr genug Zeit.",
NOT_APPROVED: "Ratenzahlung ist derzeit nicht freigegeben.",
};
+81
View File
@@ -0,0 +1,81 @@
/**
* Verbindliche Preislogik (Briefing Abschnitt 5). Reine Funktionen ohne
* Datenbank — Grundlage fuer Anzeige, Checkout und Tests.
*
* Grundpreis 2.750,00 €
* Early Bird (-20 %) 2.200,00 € 01.11.2026 00:00 – 31.12.2026 24:00 Berlin
* Regulaer 2.750,00 € ab 01.01.2027 00:00 Berlin
* Stammmodel-Code weitere -10 % auf den jeweils gueltigen Preis
* (Early Bird 1.980,00 € = -28 % gesamt, Regulaer 2.475,00 €)
*/
import { assertCents } from "./money";
import { berlinToUtc } from "./time";
export type PricePhase = "PREVIEW" | "EARLY_BIRD" | "REGULAR";
export type SellablePhase = Exclude<PricePhase, "PREVIEW">;
export const BASE_PRICE_CENTS = 275_000;
export const EARLY_BIRD_DISCOUNT_PERCENT = 20;
export const STAMMMODEL_DISCOUNT_PERCENT = 10;
/** 01.11.2026 00:00 Europe/Berlin */
export const EARLY_BIRD_START = berlinToUtc(2026, 11, 1);
/** 01.01.2027 00:00 Europe/Berlin — Ende Early Bird, Beginn Regulaer. */
export const REGULAR_START = berlinToUtc(2027, 1, 1);
export function pricePhaseAt(now: Date): PricePhase {
const t = now.getTime();
if (t < EARLY_BIRD_START.getTime()) return "PREVIEW";
if (t < REGULAR_START.getTime()) return "EARLY_BIRD";
return "REGULAR";
}
export type PriceBreakdown = {
phase: SellablePhase;
basePriceCents: number;
phaseDiscountCents: number;
/** Preis nach Phasenrabatt, vor Gutschein. */
phasePriceCents: number;
voucherDiscountCents: number;
totalCents: number;
hasStammmodelCode: boolean;
};
export function calculatePrice(phase: SellablePhase, hasStammmodelCode: boolean): PriceBreakdown {
const basePriceCents = BASE_PRICE_CENTS;
const phaseDiscountCents = phase === "EARLY_BIRD" ? percentOf(basePriceCents, EARLY_BIRD_DISCOUNT_PERCENT) : 0;
const phasePriceCents = basePriceCents - phaseDiscountCents;
// Der Code wirkt NACH dem Phasenrabatt auf den dann gueltigen Preis
// (multiplikativ, nicht additiv: 28 % statt 30 % im Early Bird).
const voucherDiscountCents = hasStammmodelCode ? percentOf(phasePriceCents, STAMMMODEL_DISCOUNT_PERCENT) : 0;
const totalCents = phasePriceCents - voucherDiscountCents;
assertCents(totalCents);
return {
phase,
basePriceCents,
phaseDiscountCents,
phasePriceCents,
voucherDiscountCents,
totalCents,
hasStammmodelCode,
};
}
/**
* Prozentanteil in ganzen Cent. Die vorgegebenen Preise ergeben immer glatte
* Cent-Betraege; alles andere waere ein Konfigurationsfehler und bricht ab,
* statt still zu runden.
*/
function percentOf(cents: number, percent: number): number {
const raw = cents * percent;
if (raw % 100 !== 0) {
throw new Error(`Rabatt ergibt keinen glatten Cent-Betrag: ${cents} × ${percent} %`);
}
return raw / 100;
}
export const PHASE_LABEL: Record<PricePhase, string> = {
PREVIEW: "Vorschau",
EARLY_BIRD: "Early Bird",
REGULAR: "Regulär",
};
+73
View File
@@ -0,0 +1,73 @@
/**
* Daten fuer die oeffentliche Onepage. Liefert ausschliesslich, was
* oeffentlich sein darf: keine Kapazitaet, keine Restplaetze, kein
* Kontingent, keine Gutscheine, keine internen Kalkulationswerte.
*/
import { getCamp, publicCampFacts, salesStateFor, type PublicCampFacts } from "./camp";
import { getDb } from "./db";
import { env, showDevPlaceholders, stripeModeOrNull } from "./env";
import { BASE_PRICE_CENTS, calculatePrice, EARLY_BIRD_START, REGULAR_START, type PricePhase } from "./pricing";
import type { SalesState, StripeMode } from "./sales-gate";
export type PublicPricing = {
mode: StripeMode | null;
checkoutOpen: boolean;
closedReason: SalesState["closedReason"] | "NOT_CONFIGURED";
phase: PricePhase;
phaseSimulated: boolean;
regularCents: number;
earlyBirdCents: number;
/** Aktuell gueltiger Preis ohne Gutschein; null in der Vorschau. */
currentCents: number | null;
earlyBirdStartIso: string;
earlyBirdEndIso: string;
installmentsAllowed: boolean;
reservationMinutes: number;
};
export type PublicPageData = {
facts: PublicCampFacts;
pricing: PublicPricing;
showDrafts: boolean;
externalVideoProvider: "none" | "youtube-nocookie" | "vimeo";
};
export async function loadPublicPage(now: Date = new Date()): Promise<PublicPageData> {
const camp = await getCamp(getDb());
const mode = stripeModeOrNull();
const facts = publicCampFacts(camp);
let pricing: PublicPricing;
const base = {
regularCents: BASE_PRICE_CENTS,
earlyBirdCents: calculatePrice("EARLY_BIRD", false).totalCents,
earlyBirdStartIso: EARLY_BIRD_START.toISOString(),
earlyBirdEndIso: REGULAR_START.toISOString(),
reservationMinutes: camp.reservationMinutes,
};
if (!mode) {
pricing = {
...base,
mode: null,
checkoutOpen: false,
closedReason: "NOT_CONFIGURED",
phase: "PREVIEW",
phaseSimulated: false,
currentCents: null,
installmentsAllowed: false,
};
} else {
const state = salesStateFor(camp, mode, now);
pricing = {
...base,
mode,
checkoutOpen: state.checkoutOpen,
closedReason: state.closedReason,
phase: state.effectivePhase,
phaseSimulated: state.phaseSimulated,
currentCents: state.effectivePhase === "PREVIEW" ? null : calculatePrice(state.effectivePhase, false).totalCents,
installmentsAllowed: state.installmentsAllowed,
};
}
return { facts, pricing, showDrafts: showDevPlaceholders(), externalVideoProvider: env().EXTERNAL_VIDEO_PROVIDER };
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Preisvorschau fuer den Buchungsdialog — rein lesend, ohne Reservierung.
* Die verbindliche Berechnung wiederholt createBooking() beim Checkout.
*/
import { campStartIso, getCamp, publicCampFacts, salesStateFor } from "./camp";
import type { Db } from "./db";
import { buildSchedule, PLAN_LABEL, PLAN_TYPES, SCHEDULE_REASON_TEXT, type PlanType } from "./payment-plan";
import { calculatePrice, type PricePhase } from "./pricing";
import { sellablePhase, type SalesState, type StripeMode } from "./sales-gate";
import { evaluateVoucher, PUBLIC_VOUCHER_REJECTION } from "./vouchers";
export type QuotePlan = {
type: PlanType;
label: string;
available: boolean;
reason?: string;
installments: { seq: number; amountCents: number; dueDate: string | null }[];
};
export type QuoteData = {
checkoutOpen: boolean;
closedReason: SalesState["closedReason"];
mode: StripeMode;
phase: PricePhase;
phaseSimulated: boolean;
price: { basePriceCents: number; phaseDiscountCents: number; voucherDiscountCents: number; totalCents: number } | null;
voucher: { state: "none" | "applied" | "rejected"; message?: string };
plans: QuotePlan[];
priceLockMinutes: number;
taxNote: string | null;
};
export async function computeQuote(
db: Db,
input: { voucherCode?: string; email?: string },
ctx: { now: Date; mode: StripeMode },
): Promise<QuoteData> {
const camp = await getCamp(db);
const state = salesStateFor(camp, ctx.mode, ctx.now);
const phase = sellablePhase(state);
let voucher: QuoteData["voucher"] = { state: "none" };
if (input.voucherCode) {
const evaluation = await evaluateVoucher(db, {
code: input.voucherCode,
email: input.email ?? null,
now: ctx.now,
livemode: ctx.mode === "live",
quota: camp.stammmodelQuota,
lock: false,
});
voucher = evaluation.ok ? { state: "applied" } : { state: "rejected", message: PUBLIC_VOUCHER_REJECTION };
}
const price = phase ? calculatePrice(phase, voucher.state === "applied") : null;
const plans: QuotePlan[] = PLAN_TYPES.map((type) => {
const base = { type, label: PLAN_LABEL[type] };
if (!price) return { ...base, available: false, reason: "Die Buchung ist noch nicht geöffnet.", installments: [] };
if (type !== "FULL" && !state.installmentsAllowed) {
const reason = camp.campStartDate ? SCHEDULE_REASON_TEXT.NOT_APPROVED : SCHEDULE_REASON_TEXT.CAMP_DATE_MISSING;
return { ...base, available: false, reason, installments: [] };
}
const schedule = buildSchedule({
planType: type,
totalCents: price.totalCents,
now: ctx.now,
campStartDate: campStartIso(camp),
finalInstallmentLeadDays: camp.finalInstallmentLeadDays,
minInstallmentGapDays: camp.minInstallmentGapDays,
});
if (!schedule.ok) return { ...base, available: false, reason: SCHEDULE_REASON_TEXT[schedule.reason], installments: [] };
return {
...base,
available: true,
installments: schedule.installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueDate: i.dueDate })),
};
});
return {
checkoutOpen: state.checkoutOpen,
closedReason: state.closedReason,
mode: ctx.mode,
phase: state.effectivePhase,
phaseSimulated: state.phaseSimulated,
price: price
? {
basePriceCents: price.basePriceCents,
phaseDiscountCents: price.phaseDiscountCents,
voucherDiscountCents: price.voucherDiscountCents,
totalCents: price.totalCents,
}
: null,
voucher,
plans,
priceLockMinutes: camp.reservationMinutes,
taxNote: publicCampFacts(camp).taxNote,
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Rate Limiting ueber PostgreSQL statt Prozessspeicher: die Anwendung laeuft
* mit mehreren Replikaten, ein Zaehler pro Pod liesse sich durch
* Lastverteilung vervielfachen.
*
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
*/
import type { Db } from "./db";
import { hmacHex } from "./tokens";
export type RateLimitRule = { action: string; limit: number; windowSeconds: number };
export const RATE_LIMITS = {
quote: { action: "quote", limit: 60, windowSeconds: 600 },
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
} satisfies Record<string, RateLimitRule>;
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
export async function consumeRateLimit(
db: Db,
rule: RateLimitRule,
identifier: string,
secret: string,
): Promise<RateLimitResult> {
const key = `${rule.action}:${hmacHex(secret, identifier).slice(0, 40)}`;
const rows = await db.$queryRaw<{ count: number; windowStart: Date }[]>`
INSERT INTO "RateLimitBucket" ("key", "windowStart", "count")
VALUES (${key}, now(), 1)
ON CONFLICT ("key") DO UPDATE SET
"count" = CASE
WHEN "RateLimitBucket"."windowStart" < now() - make_interval(secs => ${rule.windowSeconds}) THEN 1
ELSE "RateLimitBucket"."count" + 1 END,
"windowStart" = CASE
WHEN "RateLimitBucket"."windowStart" < now() - make_interval(secs => ${rule.windowSeconds}) THEN now()
ELSE "RateLimitBucket"."windowStart" END
RETURNING "count", "windowStart"`;
const row = rows[0]!;
const elapsed = (Date.now() - row.windowStart.getTime()) / 1000;
return {
allowed: row.count <= rule.limit,
retryAfterSeconds: Math.max(1, Math.ceil(rule.windowSeconds - elapsed)),
};
}
/** Veraltete Zaehler entfernen (Worker). */
export async function purgeRateLimits(db: Db): Promise<number> {
return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`;
}
/**
* Client-IP hinter Traefik. Traefik setzt X-Real-Ip selbst und ersetzt
* X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint-
* Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md).
*/
export function clientIp(headers: Headers): string {
const realIp = headers.get("x-real-ip")?.trim();
if (realIp) return realIp;
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
return forwarded || "unknown";
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Serverseitige Verkaufsfreigabe (Briefing Abschnitt 11).
*
* Jeder Checkout-Endpunkt fragt diese Funktion — nicht nur die Oberflaeche.
*
* - Stripe-Testmodus (sk_test_…): Checkout erlaubt, sobald eine Preisphase
* gilt (echt oder per testPhaseOverride simuliert). Die Checkliste wird
* angezeigt, blockiert aber nicht.
* - Stripe-Livemodus (sk_live_…): Checkout nur, wenn die echte Preisphase
* laeuft, salesEnabled gesetzt ist UND jede Freigabe bestaetigt wurde.
* testPhaseOverride wird ignoriert.
*/
import { pricePhaseAt, type PricePhase, type SellablePhase } from "./pricing";
import { berlinStartOfDay } from "./time";
export type StripeMode = "test" | "live";
export type CampGateFields = {
salesEnabled: boolean;
capacityConfirmed: boolean;
stammmodelQuotaConfirmed: boolean;
campStartDate: string | null;
dateConfirmed: boolean;
locationName: string | null;
locationConfirmed: boolean;
accommodationText: string | null;
accommodationConfirmed: boolean;
servicesConfirmed: boolean;
organizerName: string | null;
organizerAddress: string | null;
organizerEmail: string | null;
organizerConfirmed: boolean;
taxNote: string | null;
taxConfirmed: boolean;
termsVersion: string | null;
termsApproved: boolean;
travelLawChecked: boolean;
installmentsApproved: boolean;
installmentRuleApproved: boolean;
testPhaseOverride: PricePhase | null;
};
export type ReleaseBlocker =
| "SALES_DISABLED"
| "DATE"
| "LOCATION"
| "ACCOMMODATION"
| "SERVICES"
| "ORGANIZER"
| "TAX"
| "TERMS"
| "TRAVEL_LAW"
| "CAPACITY"
| "STAMMMODEL_QUOTA";
export const BLOCKER_TEXT: Record<ReleaseBlocker, string> = {
SALES_DISABLED: "Live-Verkauf ist im Adminbereich nicht eingeschaltet.",
DATE: "Camp-Termin ist nicht bestätigt.",
LOCATION: "Ort ist nicht bestätigt.",
ACCOMMODATION: "Unterkunft inkl. Zimmer- und Bettbeschreibung ist nicht bestätigt.",
SERVICES: "Enthaltene Leistungen sind nicht bestätigt.",
ORGANIZER: "Veranstalter- und Kontaktdaten fehlen oder sind nicht bestätigt.",
TAX: "Steuerlich korrekte Preisangabe ist nicht konfiguriert.",
TERMS: "Buchungs-, Storno- und Zahlungsbedingungen sind nicht freigegeben.",
TRAVEL_LAW: "Reiserechtliche Einordnung (inkl. ggf. Absicherung) ist nicht geprüft.",
CAPACITY: "Verkaufbare Platzzahl ist nicht bestätigt.",
STAMMMODEL_QUOTA: "Kontingent für Stammmodel-Codes ist nicht bestätigt.",
};
export type SalesState = {
mode: StripeMode;
/** Tatsaechliche Phase laut Kalender. */
phase: PricePhase;
/** Fuer Preise massgebliche Phase (im Testmodus ggf. simuliert). */
effectivePhase: PricePhase;
phaseSimulated: boolean;
checkoutOpen: boolean;
/** Oeffentlich zeigbarer Grund, wenn nicht buchbar. */
closedReason: "PREVIEW" | "NOT_RELEASED" | "CAMP_STARTED" | null;
releaseBlockers: ReleaseBlocker[];
installmentsAllowed: boolean;
};
export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
const blockers: ReleaseBlocker[] = [];
if (!camp.salesEnabled) blockers.push("SALES_DISABLED");
if (!camp.campStartDate || !camp.dateConfirmed) blockers.push("DATE");
if (!camp.locationName || !camp.locationConfirmed) blockers.push("LOCATION");
if (!camp.accommodationText || !camp.accommodationConfirmed) blockers.push("ACCOMMODATION");
if (!camp.servicesConfirmed) blockers.push("SERVICES");
if (!camp.organizerName || !camp.organizerAddress || !camp.organizerEmail || !camp.organizerConfirmed) {
blockers.push("ORGANIZER");
}
if (!camp.taxNote || !camp.taxConfirmed) blockers.push("TAX");
if (!camp.termsVersion || !camp.termsApproved) blockers.push("TERMS");
if (!camp.travelLawChecked) blockers.push("TRAVEL_LAW");
if (!camp.capacityConfirmed) blockers.push("CAPACITY");
if (!camp.stammmodelQuotaConfirmed) blockers.push("STAMMMODEL_QUOTA");
return blockers;
}
export function evaluateSales(now: Date, camp: CampGateFields, mode: StripeMode): SalesState {
const phase = pricePhaseAt(now);
const simulate = mode === "test" && camp.testPhaseOverride !== null && camp.testPhaseOverride !== "PREVIEW";
const effectivePhase = simulate ? camp.testPhaseOverride! : phase;
const blockers = releaseBlockers(camp);
const campStarted = camp.campStartDate !== null && now.getTime() >= berlinStartOfDay(camp.campStartDate).getTime();
let closedReason: SalesState["closedReason"] = null;
if (campStarted) closedReason = "CAMP_STARTED";
else if (effectivePhase === "PREVIEW") closedReason = "PREVIEW";
else if (mode === "live" && blockers.length > 0) closedReason = "NOT_RELEASED";
const installmentsAllowed =
mode === "test"
? camp.campStartDate !== null
: camp.installmentsApproved && camp.installmentRuleApproved && camp.dateConfirmed && camp.campStartDate !== null;
return {
mode,
phase,
effectivePhase,
phaseSimulated: simulate,
checkoutOpen: closedReason === null,
closedReason,
releaseBlockers: blockers,
installmentsAllowed,
};
}
export function sellablePhase(state: SalesState): SellablePhase | null {
return state.effectivePhase === "PREVIEW" ? null : state.effectivePhase;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Zeitrechnung in Europe/Berlin ohne Fremdbibliothek.
*
* Fachliche Grenzen (Preisphasen, Faelligkeiten) sind als Berliner Ortszeit
* definiert. Gespeichert und verglichen wird immer in UTC; diese Helfer
* uebersetzen zwischen beiden Welten und beruecksichtigen Sommer-/Winterzeit.
*/
export const TIME_ZONE = "Europe/Berlin";
const partsFormatter = new Intl.DateTimeFormat("en-US", {
timeZone: TIME_ZONE,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
type WallClock = { year: number; month: number; day: number; hour: number; minute: number; second: number };
function wallClockOf(date: Date): WallClock {
const parts = Object.fromEntries(partsFormatter.formatToParts(date).map((p) => [p.type, p.value]));
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
};
}
/** Versatz Berlin gegenueber UTC in Millisekunden zum gegebenen Zeitpunkt. */
function offsetMs(date: Date): number {
const w = wallClockOf(date);
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
return asUtc - Math.floor(date.getTime() / 1000) * 1000;
}
/** Berliner Ortszeit -> UTC-Zeitpunkt. */
export function berlinToUtc(year: number, month: number, day: number, hour = 0, minute = 0): Date {
const naive = Date.UTC(year, month - 1, day, hour, minute);
let result = naive - offsetMs(new Date(naive));
// Zweiter Durchlauf korrigiert Tage, an denen die Zeitumstellung zwischen
// Schaetzung und Ergebnis liegt.
result = naive - offsetMs(new Date(result));
return new Date(result);
}
/** Kalenderdatum (YYYY-MM-DD) des Zeitpunkts in Berlin. */
export function berlinDateString(date: Date): string {
const w = wallClockOf(date);
return `${w.year}-${pad(w.month)}-${pad(w.day)}`;
}
/** Beginn (00:00 Berlin) eines Kalenderdatums als UTC-Zeitpunkt. */
export function berlinStartOfDay(isoDate: string, hour = 0): Date {
const [y, m, d] = parseIsoDate(isoDate);
return berlinToUtc(y, m, d, hour);
}
export function addDays(isoDate: string, days: number): string {
const [y, m, d] = parseIsoDate(isoDate);
const t = new Date(Date.UTC(y, m - 1, d + days));
return t.toISOString().slice(0, 10);
}
/** Ganze Kalendertage von a nach b (b - a). */
export function daysBetween(a: string, b: string): number {
const [ay, am, ad] = parseIsoDate(a);
const [by, bm, bd] = parseIsoDate(b);
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
}
/** Datumsspalte aus PostgreSQL (UTC-Mitternacht) -> YYYY-MM-DD. */
export function dbDateToIso(date: Date): string {
return date.toISOString().slice(0, 10);
}
export function isoToDbDate(isoDate: string): Date {
const [y, m, d] = parseIsoDate(isoDate);
return new Date(Date.UTC(y, m - 1, d));
}
export function parseIsoDate(isoDate: string): [number, number, number] {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!match) throw new Error(`Ungueltiges Datum: ${isoDate}`);
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
timeZone: TIME_ZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", {
timeZone: TIME_ZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const longDateFormatter = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "numeric",
month: "long",
year: "numeric",
});
export function formatDate(date: Date): string {
return dateFormatter.format(date);
}
export function formatDateTime(date: Date): string {
return `${dateTimeFormatter.format(date)} Uhr`;
}
/** Fuer reine Datumswerte (YYYY-MM-DD), z. B. den Camp-Termin. */
export function formatIsoDateLong(isoDate: string): string {
return longDateFormatter.format(isoToDbDate(isoDate));
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
+35
View File
@@ -0,0 +1,35 @@
import { createHash, createHmac, randomInt } from "node:crypto";
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function hmacHex(secret: string, value: string): string {
return createHmac("sha256", secret).update(value).digest("hex");
}
/**
* Zugangstoken fuer die Buchungsstatusseite.
*
* Deterministisch aus Buchungs-ID und APP_SECRET abgeleitet: so laesst es
* sich fuer jede E-Mail neu erzeugen, ohne es speichern zu muessen. In der
* Datenbank liegt nur sein SHA-256 (Booking.statusTokenHash) fuer die Suche.
* Ohne APP_SECRET ist es nicht zu erraten; ein Wechsel des Secrets macht alle
* bisherigen Statuslinks ungueltig.
*/
export function statusTokenFor(bookingId: string, secret: string): string {
return createHmac("sha256", secret).update(`booking-status:${bookingId}`).digest("base64url");
}
export function isWellFormedStatusToken(token: string): boolean {
return /^[A-Za-z0-9_-]{43}$/.test(token);
}
// Ohne leicht verwechselbare Zeichen (0/O, 1/I/L).
const REFERENCE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
export function generateReference(): string {
let suffix = "";
for (let i = 0; i < 6; i++) suffix += REFERENCE_ALPHABET[randomInt(REFERENCE_ALPHABET.length)];
return `AMC27-${suffix}`;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Stammmodel-Codes (Briefing Abschnitt 6).
*
* Oeffentlich gibt es nur eine einzige, neutrale Ablehnung — ob ein Code
* unbekannt, abgelaufen oder ausgeschoepft ist, erfaehrt nur der
* Adminbereich. So laesst sich die Existenz gueltiger Codes nicht
* durchprobieren.
*/
import type { Voucher } from "@/generated/prisma/client";
import type { Db, Tx } from "./db";
export const PUBLIC_VOUCHER_REJECTION = "Dieser Gutscheincode kann leider nicht angewendet werden.";
export type VoucherRejection =
| "MALFORMED"
| "UNKNOWN"
| "INACTIVE"
| "NOT_YET_VALID"
| "EXPIRED"
| "EMAIL_MISMATCH"
| "EXHAUSTED"
| "QUOTA_EXHAUSTED";
/** Grossbuchstaben, ohne Leerzeichen; null bei offensichtlich ungueltiger Form. */
export function normalizeVoucherCode(input: string): string | null {
const code = input.replace(/\s+/g, "").toUpperCase();
return /^[A-Z0-9-]{4,40}$/.test(code) ? code : null;
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
type StaticVoucherFields = Pick<Voucher, "active" | "validFrom" | "validUntil" | "boundEmail">;
export function checkVoucherStatic(
voucher: StaticVoucherFields,
ctx: { now: Date; email: string | null },
): VoucherRejection | null {
if (!voucher.active) return "INACTIVE";
if (voucher.validFrom && ctx.now < voucher.validFrom) return "NOT_YET_VALID";
if (voucher.validUntil && ctx.now >= voucher.validUntil) return "EXPIRED";
if (voucher.boundEmail) {
// Ohne E-Mail (reine Preisvorschau) laesst sich die Bindung nicht
// pruefen — die verbindliche Pruefung passiert beim Checkout.
if (ctx.email !== null && normalizeEmail(ctx.email) !== normalizeEmail(voucher.boundEmail)) {
return "EMAIL_MISMATCH";
}
}
return null;
}
/**
* Zaehlt belegte Einloesungen: abgeschlossene plus reservierte, deren
* Buchung ihren Platz noch haelt. Ohne voucherId: Gesamtkontingent ueber
* alle Stammmodel-Codes.
*/
export async function countUsedRedemptions(
db: Db | Tx,
params: { livemode: boolean; now: Date; voucherId?: string },
): Promise<number> {
const rows = await db.$queryRaw<{ n: number }[]>`
SELECT count(*)::int AS n
FROM "VoucherRedemption" r
JOIN "SeatReservation" s ON s."bookingId" = r."bookingId"
WHERE r."livemode" = ${params.livemode}
AND (${params.voucherId ?? null}::text IS NULL OR r."voucherId" = ${params.voucherId ?? null})
AND (
r."status" = 'COMPLETED'
OR (r."status" = 'RESERVED' AND (
s."status" IN ('HELD', 'CONVERTED')
OR (s."status" = 'ACTIVE' AND s."expiresAt" > ${params.now})
))
)`;
return rows[0]?.n ?? 0;
}
export type VoucherEvaluation =
| { ok: true; voucher: Voucher }
| { ok: false; reason: VoucherRejection };
/**
* Vollstaendige Pruefung eines Codes inklusive Einloeselimit und
* Gesamtkontingent. Mit lock=true wird die Gutscheinzeile gesperrt (nur
* innerhalb einer Transaktion, die bereits die Camp-Zeile haelt).
*/
export async function evaluateVoucher(
db: Db | Tx,
params: { code: string; email: string | null; now: Date; livemode: boolean; quota: number; lock: boolean },
): Promise<VoucherEvaluation> {
const code = normalizeVoucherCode(params.code);
if (!code) return { ok: false, reason: "MALFORMED" };
if (params.lock) {
await db.$queryRaw`SELECT "id" FROM "Voucher" WHERE "code" = ${code} FOR UPDATE`;
}
const voucher = await db.voucher.findUnique({ where: { code } });
if (!voucher) return { ok: false, reason: "UNKNOWN" };
const rejection = checkVoucherStatic(voucher, { now: params.now, email: params.email });
if (rejection) return { ok: false, reason: rejection };
const used = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now, voucherId: voucher.id });
if (used >= voucher.maxRedemptions) return { ok: false, reason: "EXHAUSTED" };
const quotaUsed = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now });
if (quotaUsed >= params.quota) return { ok: false, reason: "QUOTA_EXHAUSTED" };
return { ok: true, voucher };
}
export const VOUCHER_REJECTION_ADMIN_TEXT: Record<VoucherRejection, string> = {
MALFORMED: "Format ungültig",
UNKNOWN: "Unbekannter Code",
INACTIVE: "Code ist deaktiviert",
NOT_YET_VALID: "Gültigkeit hat noch nicht begonnen",
EXPIRED: "Gültigkeit abgelaufen",
EMAIL_MISMATCH: "An andere E-Mail-Adresse gebunden",
EXHAUSTED: "Maximale Einlösungen erreicht",
QUOTA_EXHAUSTED: "Gesamtkontingent für Stammmodel-Buchungen erreicht",
};
+252
View File
@@ -0,0 +1,252 @@
/**
* Verarbeitung verifizierter Stripe-Events.
*
* - Idempotenz: Jede Event-ID wird in StripeEvent festgehalten; ein bereits
* verarbeitetes Event wird beim erneuten Zustellen uebersprungen.
* - Reihenfolge: Die Handler in payment-events.ts sind monoton und sperren
* die Buchung — ungeordnete Events (z. B. "expired" nach "completed")
* richten keinen Schaden an.
* - Wahrheit: Fuer Zahlungen wird der PaymentIntent bei Stripe nachgeladen,
* statt dem ggf. veralteten Event-Inhalt zu vertrauen.
*/
import type Stripe from "stripe";
import { Prisma } from "@/generated/prisma/client";
import { STRIPE_ACTOR, type Actor } from "./audit";
import type { Db } from "./db";
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
import {
applyCheckoutExpired,
applyDispute,
applyFirstPaymentFailed,
applyFirstPaymentProcessing,
applyFirstPaymentSucceeded,
applyInstallmentFailed,
applyInstallmentPaid,
applyRefundState,
type EventContext,
} from "./payment-events";
export type HandlerContext = { now: Date; secret: string; adminEmail: string | null };
export type WebhookResult = { outcome: "processed" | "ignored" | "duplicate"; bookingIds: string[] };
function idOf(value: string | { id: string } | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
function eventCtx(ctx: HandlerContext, actor: Actor = STRIPE_ACTOR): EventContext {
return { now: ctx.now, secret: ctx.secret, adminEmail: ctx.adminEmail, actor };
}
export function sessionInfoFromStripe(s: Stripe.Checkout.Session): CheckoutSessionInfo {
return {
id: s.id,
status: (s.status ?? "open") as CheckoutSessionInfo["status"],
paymentStatus: normalizePaymentStatus(s.payment_status),
paymentIntentId: idOf(s.payment_intent),
customerId: idOf(s.customer),
url: s.url ?? null,
metadata: (s.metadata ?? {}) as Record<string, string>,
};
}
/**
* Bringt eine abgeschlossene Checkout-Session in die Datenbank. Wird vom
* Webhook UND vom Worker (Abgleich bei verlorenem Webhook) genutzt.
*/
export async function settleCheckoutSession(
db: Db,
gateway: PaymentGateway,
session: CheckoutSessionInfo,
ctx: HandlerContext,
actor: Actor = STRIPE_ACTOR,
): Promise<string | null> {
const { kind, bookingId, installmentId } = session.metadata;
if (!bookingId || (kind !== "booking" && kind !== "installment")) return null;
const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } });
if (!booking) return null;
// Eine Buchungs-Session muss zur gespeicherten Session passen (oder es ist
// noch keine gespeichert, weil das Speichern nach Stripe scheiterte).
if (kind === "booking" && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
const paid = session.paymentStatus === "paid";
if (paid) {
if (!session.paymentIntentId) throw new Error(`Bezahlte Session ${session.id} ohne PaymentIntent.`);
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
const info = {
paymentIntentId: pi.id,
chargeId: pi.chargeId,
paymentMethodId: pi.paymentMethodId,
customerId: session.customerId,
amountCents: pi.amountReceived,
};
if (kind === "booking") {
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
} else if (installmentId) {
await db.$transaction((tx) => applyInstallmentPaid(tx, installmentId, info, eventCtx(ctx, actor)));
}
return bookingId;
}
if (session.status === "complete") {
if (kind === "booking") {
await db.$transaction((tx) =>
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
);
} else if (installmentId) {
await db.installment.updateMany({
where: { id: installmentId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
data: { status: "PROCESSING", stripePaymentIntentId: session.paymentIntentId, lastAttemptAt: ctx.now },
});
}
}
return bookingId;
}
async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded": {
const id = await settleCheckoutSession(db, gateway, sessionInfoFromStripe(event.data.object), ctx);
return id ? [id] : [];
}
case "checkout.session.async_payment_failed": {
const s = sessionInfoFromStripe(event.data.object);
const { kind, bookingId, installmentId } = s.metadata;
if (kind === "booking" && bookingId) {
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
return [bookingId];
}
if (kind === "installment" && installmentId) {
await db.installment.updateMany({
where: { id: installmentId, status: "PROCESSING" },
data: { status: "FAILED", failureCode: "async_payment_failed", checkoutSessionId: null, checkoutExpiresAt: null },
});
return bookingId ? [bookingId] : [];
}
return [];
}
case "checkout.session.expired": {
const s = event.data.object;
const { kind, bookingId, installmentId } = (s.metadata ?? {}) as Record<string, string>;
if (kind === "booking" && bookingId) {
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
return [bookingId];
}
if (kind === "installment" && installmentId) {
await db.installment.updateMany({
where: { id: installmentId, checkoutSessionId: s.id },
data: { checkoutSessionId: null, checkoutExpiresAt: null },
});
}
return [];
}
case "payment_intent.succeeded": {
const pi = event.data.object;
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
const outcome = await db.$transaction((tx) =>
applyInstallmentPaid(
tx,
pi.metadata.installmentId!,
{
paymentIntentId: pi.id,
chargeId: idOf(pi.latest_charge),
paymentMethodId: idOf(pi.payment_method),
customerId: idOf(pi.customer),
amountCents: pi.amount_received,
},
eventCtx(ctx),
),
);
void outcome;
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
}
case "payment_intent.payment_failed": {
const pi = event.data.object;
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
const code = pi.last_payment_error?.code ?? null;
await db.$transaction((tx) =>
applyInstallmentFailed(
tx,
pi.metadata.installmentId!,
{
paymentIntentId: pi.id,
failureCode: pi.last_payment_error?.decline_code ?? code,
requiresAction: code === "authentication_required" || pi.status === "requires_action",
},
eventCtx(ctx),
),
);
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
}
case "charge.refunded": {
const charge = event.data.object;
const piId = idOf(charge.payment_intent);
if (!piId) return [];
await db.$transaction((tx) =>
applyRefundState(tx, { paymentIntentId: piId, amountRefundedCents: charge.amount_refunded }, eventCtx(ctx)),
);
return [];
}
case "refund.created":
case "refund.updated": {
const refund = event.data.object;
await db.refund.updateMany({ where: { stripeRefundId: refund.id }, data: { status: refund.status ?? "unknown" } });
return [];
}
case "charge.dispute.created": {
const piId = idOf(event.data.object.payment_intent);
if (piId) await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
return [];
}
default:
return [];
}
}
const HANDLED = new Set<string>([
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"charge.refunded",
"refund.created",
"refund.updated",
"charge.dispute.created",
]);
export async function handleStripeEvent(
db: Db,
gateway: PaymentGateway,
event: Stripe.Event,
ctx: HandlerContext,
): Promise<WebhookResult> {
const livemode = gateway.mode === "live";
const relevant = HANDLED.has(event.type) && event.livemode === livemode;
try {
await db.stripeEvent.create({
data: { id: event.id, type: event.type, livemode: event.livemode, status: relevant ? "PROCESSING" : "IGNORED" },
});
} catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) throw error;
const existing = await db.stripeEvent.findUniqueOrThrow({ where: { id: event.id } });
if (existing.status === "PROCESSED" || existing.status === "IGNORED") return { outcome: "duplicate", bookingIds: [] };
// PROCESSING (paralleler/abgebrochener Versuch) oder FAILED: erneut
// verarbeiten — die Handler sind idempotent.
await db.stripeEvent.update({ where: { id: event.id }, data: { attempts: { increment: 1 } } });
}
if (!relevant) return { outcome: "ignored", bookingIds: [] };
try {
const bookingIds = await dispatch(db, gateway, event, ctx);
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } });
return { outcome: "processed", bookingIds };
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler";
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "FAILED", error: message } });
throw error;
}
}
+298
View File
@@ -0,0 +1,298 @@
/**
* Hintergrundaufgaben, ausgefuehrt vom Kubernetes-CronJob (scripts/worker.ts).
*
* Kein In-Process-Timer: Faelligkeiten stehen in der Datenbank, jeder Lauf
* arbeitet den aktuellen Stand ab. Mehrere gleichzeitige Laeufe (z. B. ein
* manueller Start waehrend des CronJobs) sind unschaedlich, weil jede Rate
* vor dem Einzug atomar beansprucht wird und Stripe-Aufrufe
* Idempotenzschluessel tragen.
*/
import { audit, WORKER } from "./audit";
import type { Db } from "./db";
import { flushEmails } from "./email";
import { MAX_OFF_SESSION_ATTEMPTS, applyCheckoutExpired, applyFirstPaymentFailed, applyInstallmentFailed, applyInstallmentPaid, type EventContext } from "./payment-events";
import type { PaymentGateway } from "./payment-gateway";
import { purgeRateLimits } from "./rate-limit";
import { settleCheckoutSession, type HandlerContext } from "./webhook";
/** Stripe-Idempotenzschluessel gelten 24 Stunden; ein haengender Einzug
* wird nur innerhalb dieser Frist automatisch mit demselben Schluessel
* wiederholt, danach entscheidet ein Mensch. */
const SAFE_RETRY_WINDOW_MS = 20 * 60 * 60_000;
const STUCK_AFTER_MS = 2 * 60 * 60_000;
function eventCtx(ctx: HandlerContext): EventContext {
return { ...ctx, actor: WORKER };
}
/**
* Gibt abgelaufene Reservierungen frei. Vorher wird immer Stripe gefragt:
* wurde die Session doch bezahlt (Webhook verloren), wird die Zahlung
* verbucht statt der Platz freigegeben.
*/
export async function releaseExpiredReservations(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const expired = await db.seatReservation.findMany({
where: { status: "ACTIVE", expiresAt: { lt: ctx.now } },
include: { booking: { select: { id: true, livemode: true, stripeCheckoutSessionId: true } } },
take: 100,
});
let released = 0;
let settled = 0;
for (const reservation of expired) {
const booking = reservation.booking;
const sameMode = booking.livemode === (gateway.mode === "live");
if (booking.stripeCheckoutSessionId && sameMode) {
let session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId);
if (session.status === "open") {
await gateway.expireCheckoutSession(session.id);
session = await gateway.retrieveCheckoutSession(session.id);
}
if (session.status === "complete") {
await settleCheckoutSession(db, gateway, session, ctx, WORKER);
settled++;
continue;
}
}
const result = await db.$transaction((tx) =>
applyCheckoutExpired(tx, booking.id, null, { ...eventCtx(ctx), reason: "reservation_expired" }),
);
if (result === "released") released++;
}
return { released, settled };
}
/** Zieht faellige Folgeraten off-session ein. */
export async function chargeDueInstallments(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const livemode = gateway.mode === "live";
const due = await db.installment.findMany({
where: {
seq: { gt: 1 },
booking: { status: "CONFIRMED", livemode },
OR: [
{ status: "PENDING", dueAt: { lte: ctx.now } },
{ status: "FAILED", nextAttemptAt: { lte: ctx.now }, attemptCount: { lt: MAX_OFF_SESSION_ATTEMPTS } },
],
AND: [{ OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }] }],
},
orderBy: { dueAt: "asc" },
take: 50,
});
const summary = { charged: 0, failed: 0, requiresAction: 0, processing: 0, skipped: 0 };
for (const installment of due) {
// Atomar beanspruchen: nur ein Lauf darf diese Rate einziehen.
const claimed = await db.installment.updateMany({
where: {
id: installment.id,
status: installment.status,
OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }],
},
data: { status: "PROCESSING", lastAttemptAt: ctx.now },
});
if (claimed.count === 0) {
summary.skipped++;
continue;
}
const booking = await db.booking.findUniqueOrThrow({ where: { id: installment.bookingId } });
// Schutz gegen jede Abbuchung ueber den vereinbarten Gesamtpreis hinaus,
// insbesondere nach der letzten Rate.
if (booking.paidCents + installment.amountCents > booking.totalCents) {
await db.installment.update({ where: { id: installment.id }, data: { status: "CANCELED", failureCode: "plan_already_paid" } });
await audit(db, WORKER, "installment.skipped_overpayment_guard", "Installment", installment.id, {
paidCents: booking.paidCents,
totalCents: booking.totalCents,
});
summary.skipped++;
continue;
}
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
await db.$transaction((tx) =>
applyInstallmentFailed(tx, installment.id, { paymentIntentId: null, failureCode: "no_saved_payment_method", requiresAction: true }, eventCtx(ctx)),
);
summary.requiresAction++;
continue;
}
let result;
try {
result = await gateway.chargeOffSession(
{
customerId: booking.stripeCustomerId,
paymentMethodId: booking.stripePaymentMethodId,
amountCents: installment.amountCents,
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Rate ${installment.seq}`,
metadata: {
kind: "installment_offsession",
bookingId: booking.id,
installmentId: installment.id,
reference: booking.reference,
},
},
// Gleicher Schluessel bei Wiederholung nach Netzwerkfehler: Stripe
// liefert dann das erste Ergebnis statt ein zweites Mal abzubuchen.
`offsession:${installment.id}:${installment.attemptCount}`,
);
} catch (error) {
console.error(`[worker] Einzug Rate ${installment.id} unterbrochen: ${error instanceof Error ? error.message : "unbekannt"}`);
await db.installment.updateMany({
where: { id: installment.id, status: "PROCESSING" },
data: { status: installment.status, nextAttemptAt: new Date(ctx.now.getTime() + 60 * 60_000) },
});
summary.failed++;
continue;
}
if (result.outcome === "succeeded" && result.paymentIntentId) {
await db.$transaction((tx) =>
applyInstallmentPaid(
tx,
installment.id,
{
paymentIntentId: result.paymentIntentId!,
chargeId: result.chargeId,
paymentMethodId: booking.stripePaymentMethodId,
customerId: booking.stripeCustomerId,
amountCents: installment.amountCents,
},
eventCtx(ctx),
),
);
summary.charged++;
} else if (result.outcome === "processing") {
await db.installment.update({ where: { id: installment.id }, data: { stripePaymentIntentId: result.paymentIntentId } });
summary.processing++;
} else {
await db.$transaction((tx) =>
applyInstallmentFailed(
tx,
installment.id,
{
paymentIntentId: result.paymentIntentId,
failureCode: result.failureCode,
requiresAction: result.outcome === "requires_action",
},
eventCtx(ctx),
),
);
if (result.outcome === "requires_action") summary.requiresAction++;
else summary.failed++;
}
}
return summary;
}
/** Faengt haengende Einzuege und verlorene Webhooks auf. */
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const stuck = await db.installment.findMany({
where: { status: "PROCESSING", lastAttemptAt: { lt: new Date(ctx.now.getTime() - STUCK_AFTER_MS) }, seq: { gt: 1 } },
include: { booking: { select: { livemode: true } } },
take: 50,
});
let resolved = 0;
for (const installment of stuck) {
if (installment.booking.livemode !== (gateway.mode === "live")) continue;
if (installment.stripePaymentIntentId) {
const pi = await gateway.retrievePaymentIntent(installment.stripePaymentIntentId);
if (pi.status === "succeeded") {
await db.$transaction((tx) =>
applyInstallmentPaid(
tx,
installment.id,
{ paymentIntentId: pi.id, chargeId: pi.chargeId, paymentMethodId: pi.paymentMethodId, customerId: null, amountCents: pi.amountReceived },
eventCtx(ctx),
),
);
resolved++;
} else if (pi.status === "requires_payment_method" || pi.status === "canceled" || pi.status === "requires_action") {
await db.$transaction((tx) =>
applyInstallmentFailed(
tx,
installment.id,
{ paymentIntentId: pi.id, failureCode: `stuck_${pi.status}`, requiresAction: pi.status === "requires_action" },
eventCtx(ctx),
),
);
resolved++;
}
continue;
}
// Kein Ergebnis gespeichert (z. B. Absturz waehrend des Aufrufs).
if (installment.lastAttemptAt && ctx.now.getTime() - installment.lastAttemptAt.getTime() < SAFE_RETRY_WINDOW_MS) {
// attemptCount bleibt gleich -> naechster Lauf nutzt denselben
// Idempotenzschluessel und kann nicht doppelt abbuchen.
await db.installment.updateMany({
where: { id: installment.id, status: "PROCESSING" },
data: { status: "FAILED", nextAttemptAt: ctx.now, failureCode: "worker_interrupted" },
});
} else {
await db.installment.updateMany({
where: { id: installment.id, status: "PROCESSING" },
data: { status: "REQUIRES_ACTION", failureCode: "manual_check_required" },
});
await audit(db, WORKER, "installment.manual_check_required", "Installment", installment.id);
}
resolved++;
}
// Verzoegerte erste Zahlungen (SEPA), deren Ergebnis-Webhook ausblieb.
const processing = await db.booking.findMany({
where: {
status: "PENDING",
paymentStatus: "PROCESSING",
livemode: gateway.mode === "live",
updatedAt: { lt: new Date(ctx.now.getTime() - 24 * 60 * 60_000) },
stripeCheckoutSessionId: { not: null },
},
take: 50,
});
for (const booking of processing) {
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
if (session.paymentStatus === "paid") {
await settleCheckoutSession(db, gateway, session, ctx, WORKER);
resolved++;
} else if (session.paymentIntentId) {
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
if (pi.status === "requires_payment_method" || pi.status === "canceled") {
await db.$transaction((tx) => applyFirstPaymentFailed(tx, booking.id, eventCtx(ctx)));
resolved++;
}
}
}
return { resolved };
}
/**
* Trennt die gespeicherte Zahlungsmethode, sobald keine Rate mehr offen ist
* (vollstaendig bezahlt oder storniert). Danach ist technisch keine weitere
* Abbuchung mehr moeglich.
*/
export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGateway, bookingIds?: string[]) {
const candidates = await db.booking.findMany({
where: {
stripePaymentMethodId: { not: null },
paymentMethodDetachedAt: null,
livemode: gateway.mode === "live",
...(bookingIds ? { id: { in: bookingIds } } : {}),
installments: { none: { status: { in: ["PENDING", "PROCESSING", "FAILED", "REQUIRES_ACTION"] } } },
},
take: 50,
});
for (const booking of candidates) {
await gateway.detachPaymentMethod(booking.stripePaymentMethodId!);
await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } });
await audit(db, WORKER, "payment_method.detached", "Booking", booking.id, { paymentMethodId: booking.stripePaymentMethodId });
}
return { detached: candidates.length };
}
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const reservations = await releaseExpiredReservations(db, gateway, ctx);
const reconcile = await reconcileStuck(db, gateway, ctx);
const installments = await chargeDueInstallments(db, gateway, ctx);
const detach = await detachFinishedPaymentMethods(db, gateway);
const emails = await flushEmails(db, { limit: 200 });
const purged = await purgeRateLimits(db);
return { reservations, reconcile, installments, detach, emails, purgedRateLimits: purged };
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Deutsche Standard-Fehlermeldungen fuer Zod. Wird von allen Modulen mit
* Eingabevalidierung importiert, damit auch nicht eigens formulierte Fehler
* verstaendlich auf Deutsch erscheinen.
*/
import { z } from "zod";
import { de } from "zod/v4/locales";
z.config(de());
export {};