/** * 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 { stopCanceledInstallmentPlans } from "./installment-billing"; 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 { 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 Session schliessen, Stripe-Ratenplan beenden und gespeicherte // Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr // abgebucht werden. if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined); const plan = await stopCanceledInstallmentPlans(db, gateway, [before.id], actor); if (plan.failed > 0) { return { ok: false, message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.", }; } 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 { 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 { 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 { 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; export async function saveVoucher(db: Db, id: string | null, input: VoucherInput, actor: Actor): Promise { 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(), withdrawalText: text(10000), withdrawalApproved: 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; export async function updateCamp(db: Db, input: CampInput, actor: Actor): Promise { 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." }; }