first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+247
View File
@@ -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." };
}