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]>
258 lines
13 KiB
TypeScript
258 lines
13 KiB
TypeScript
/**
|
||
* 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." };
|
||
}
|