This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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." };
|
||||
}
|
||||
@@ -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) },
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {};
|
||||
Reference in new issue
Block a user