This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -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." };
|
||||
}
|
||||
Reference in new issue
Block a user