Files
Artista-Filmcamp/src/lib/admin-service.ts
T
Nick SzaunigandClaude Opus 5.5 a28d18c3e6 fix: Worker übersteht Stripe-Fehler einzelner Buchungen, Rechnungsentwürfe beim Beenden löschen
Befunde aus dem Sicherheits-Review:

- Anhalten, Beenden und Abgleich der Ratenpläne fangen Fehler je Buchung ab.
  Ein Stripe-Fehler bei einer Buchung bricht nicht mehr den ganzen
  Worker-Lauf ab (E-Mails, Reservierungen liefen sonst nicht weiter).
  Teilfehler setzen den Exit-Code 1, damit der CronJob-Lauf sichtbar
  fehlschlägt.
- Beim Beenden eines Plans werden neben offenen Rechnungen auch Entwürfe
  gelöscht. Laut Stripe-Testuhr bleibt ein Entwurf nach dem Beenden zwar
  liegen und wird nicht eingezogen; das Löschen schließt das aus.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:34:20 +02:00

258 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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 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<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(),
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<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." };
}