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

+379
View File
@@ -0,0 +1,379 @@
/**
* Zustandsuebergaenge fuer Zahlungen.
*
* Grundsaetze:
* - Jede Funktion laeuft in einer Transaktion und sperrt zuerst die
* Buchungszeile. Doppelte, verspaetete oder ungeordnete Stripe-Events
* treffen dadurch immer auf einen konsistenten Stand.
* - Uebergaenge sind monoton: Eine bezahlte Rate wird nie wieder offen, ein
* spaetes "expired" hebt keine bezahlte Buchung auf.
* - Buchungsstatus (BookingStatus) und Zahlungsstatus (PaymentStatus) sind
* getrennt; eine bezahlte erste Rate heisst bei Ratenzahlung PARTIALLY_PAID.
* - Was nicht automatisch sicher entschieden werden kann (verspaetete Zahlung
* ohne freien Platz, Doppelzahlung, Rueckbuchung), landet als
* NEEDS_REVIEW mit Begruendung im Adminbereich — nie still verworfen.
*/
import type { Booking, Installment, PaymentStatus } from "@/generated/prisma/client";
import { audit, type Actor } from "./audit";
import { countOccupiedSeats, lockBooking, lockCamp, releaseBookingHold } from "./booking-service";
import { getCamp } from "./camp";
import type { Tx } from "./db";
import { enqueueEmail } from "./email";
import {
adminReviewEmail,
bookingConfirmedEmail,
installmentActionEmail,
installmentPaidEmail,
paymentFailedEmail,
paymentProcessingEmail,
reviewCustomerEmail,
} from "./email-templates";
import { statusTokenFor } from "./tokens";
import { countUsedRedemptions } from "./vouchers";
export const MAX_OFF_SESSION_ATTEMPTS = 3;
export const RETRY_DELAY_DAYS = 3;
export type EventContext = { now: Date; secret: string; actor: Actor; adminEmail: string | null };
export type PaymentInfo = {
paymentIntentId: string;
chargeId: string | null;
paymentMethodId: string | null;
customerId: string | null;
amountCents: number;
};
type FullBooking = Booking & { installments: Installment[] };
async function loadLocked(tx: Tx, bookingId: string) {
await lockBooking(tx, bookingId);
return tx.booking.findUniqueOrThrow({
where: { id: bookingId },
include: { installments: { orderBy: { seq: "asc" } }, reservation: true, redemption: true },
});
}
function paymentStatusFor(paidCents: number, totalCents: number, refundedCents: number): PaymentStatus {
if (refundedCents > 0) return refundedCents >= paidCents ? "REFUNDED" : "PARTIALLY_REFUNDED";
if (paidCents <= 0) return "UNPAID";
return paidCents >= totalCents ? "PAID" : "PARTIALLY_PAID";
}
async function flagForReview(tx: Tx, booking: Booking, reason: string, ctx: EventContext): Promise<void> {
await tx.booking.update({ where: { id: booking.id }, data: { status: "NEEDS_REVIEW", reviewReason: reason } });
await audit(tx, ctx.actor, "booking.needs_review", "Booking", booking.id, { reason });
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, booking, reason));
}
export type FirstPaymentOutcome = "confirmed" | "duplicate" | "needs_review";
/** Erste Rate (bzw. Einmalzahlung) ist bei Stripe erfolgreich eingegangen. */
export async function applyFirstPaymentSucceeded(
tx: Tx,
bookingId: string,
info: PaymentInfo,
ctx: EventContext,
): Promise<FirstPaymentOutcome> {
const booking = await loadLocked(tx, bookingId);
const first = booking.installments.find((i) => i.seq === 1)!;
if (first.status === "PAID") {
if (first.stripePaymentIntentId && first.stripePaymentIntentId !== info.paymentIntentId) {
await flagForReview(tx, booking, "Doppelzahlung der ersten Rate – bitte eine Zahlung erstatten.", ctx);
return "needs_review";
}
return "duplicate";
}
const problems: string[] = [];
if (info.amountCents !== first.amountCents) {
problems.push(`Betrag weicht ab (erwartet ${first.amountCents}, erhalten ${info.amountCents} Cent).`);
}
// Platz: normalerweise noch reserviert. Wurde er inzwischen freigegeben
// (Zahlung kam nach Ablauf an), wird er nur uebernommen, wenn noch frei.
const reservation = booking.reservation!;
if (reservation.status === "ACTIVE" || reservation.status === "HELD") {
await tx.seatReservation.update({ where: { id: reservation.id }, data: { status: "CONVERTED" } });
} else if (reservation.status === "RELEASED") {
await lockCamp(tx);
const camp = await getCamp(tx);
const occupied = await countOccupiedSeats(tx, booking.livemode, ctx.now);
if (occupied < camp.capacity) {
await tx.seatReservation.update({
where: { id: reservation.id },
data: { status: "CONVERTED", releasedAt: null, releaseReason: null },
});
await audit(tx, ctx.actor, "seat.reacquired_after_late_payment", "Booking", booking.id, { occupiedBefore: occupied });
} else {
problems.push("Zahlung nach Ablauf der Reservierung eingegangen, inzwischen ist kein Platz mehr frei.");
}
}
const redemption = booking.redemption;
if (redemption?.status === "RESERVED") {
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED" } });
} else if (redemption?.status === "RELEASED") {
await tx.$queryRaw`SELECT "id" FROM "Voucher" WHERE "id" = ${redemption.voucherId} FOR UPDATE`;
const [voucher, camp] = await Promise.all([
tx.voucher.findUniqueOrThrow({ where: { id: redemption.voucherId } }),
getCamp(tx),
]);
const used = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now, voucherId: voucher.id });
const quotaUsed = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now });
if (used < voucher.maxRedemptions && quotaUsed < camp.stammmodelQuota) {
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED", releasedAt: null } });
} else {
problems.push("Gutscheincode war bei verspäteter Zahlung nicht mehr verfügbar.");
}
}
await tx.installment.update({
where: { id: first.id },
data: {
status: "PAID",
paidAt: ctx.now,
stripePaymentIntentId: info.paymentIntentId,
stripeChargeId: info.chargeId,
},
});
const paidCents = booking.paidCents + info.amountCents;
const updated = await tx.booking.update({
where: { id: booking.id },
data: {
status: problems.length > 0 ? "NEEDS_REVIEW" : "CONFIRMED",
reviewReason: problems.length > 0 ? problems.join(" ") : null,
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
paidCents,
confirmedAt: problems.length > 0 ? null : ctx.now,
stripeCustomerId: info.customerId ?? booking.stripeCustomerId,
// Nur bei Ratenzahlung gespeichert; bei Einmalzahlung gibt es nichts
// wiederzuverwenden.
stripePaymentMethodId: booking.planType === "FULL" ? null : info.paymentMethodId,
},
include: { installments: { orderBy: { seq: "asc" } } },
});
await audit(tx, ctx.actor, "payment.first_succeeded", "Booking", booking.id, {
paymentIntentId: info.paymentIntentId,
amountCents: info.amountCents,
paidCents,
previousStatus: booking.status,
problems,
});
const token = statusTokenFor(booking.id, ctx.secret);
if (problems.length > 0) {
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, updated, problems.join(" ")));
await enqueueEmail(tx, reviewCustomerEmail(updated, token));
return "needs_review";
}
await enqueueEmail(tx, bookingConfirmedEmail(updated, token));
return "confirmed";
}
/** Checkout abgeschlossen, Zahlung aber noch unterwegs (z. B. SEPA-Lastschrift). */
export async function applyFirstPaymentProcessing(
tx: Tx,
bookingId: string,
info: { paymentIntentId: string | null; customerId: string | null },
ctx: EventContext,
): Promise<"processing" | "ignored"> {
const booking = await loadLocked(tx, bookingId);
const first = booking.installments.find((i) => i.seq === 1)!;
if (booking.status !== "PENDING" || first.status !== "PENDING") return "ignored";
await tx.seatReservation.updateMany({ where: { bookingId, status: "ACTIVE" }, data: { status: "HELD" } });
await tx.installment.update({
where: { id: first.id },
data: { status: "PROCESSING", stripePaymentIntentId: info.paymentIntentId },
});
const updated = await tx.booking.update({
where: { id: bookingId },
data: { paymentStatus: "PROCESSING", stripeCustomerId: info.customerId ?? booking.stripeCustomerId },
});
await audit(tx, ctx.actor, "payment.first_processing", "Booking", bookingId, { paymentIntentId: info.paymentIntentId });
await enqueueEmail(tx, paymentProcessingEmail(updated, statusTokenFor(bookingId, ctx.secret)));
return "processing";
}
/** Verzoegerte erste Zahlung endgueltig fehlgeschlagen: Platz freigeben. */
export async function applyFirstPaymentFailed(tx: Tx, bookingId: string, ctx: EventContext): Promise<"failed" | "ignored"> {
const booking = await loadLocked(tx, bookingId);
const first = booking.installments.find((i) => i.seq === 1)!;
if (first.status === "PAID" || booking.status === "CANCELED") return "ignored";
await tx.installment.update({ where: { id: first.id }, data: { status: "FAILED", failureCode: "async_payment_failed" } });
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: "payment_failed", now: ctx.now });
const updated = await tx.booking.update({ where: { id: bookingId }, data: { paymentStatus: "FAILED" } });
await audit(tx, ctx.actor, "payment.first_failed", "Booking", bookingId);
await enqueueEmail(tx, paymentFailedEmail(updated));
return "failed";
}
/** Checkout-Session abgelaufen oder abgebrochen: nur unbezahlte Buchungen freigeben. */
export async function applyCheckoutExpired(
tx: Tx,
bookingId: string,
sessionId: string | null,
ctx: EventContext & { reason?: string },
): Promise<"released" | "ignored"> {
const booking = await loadLocked(tx, bookingId);
if (sessionId && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== sessionId) return "ignored";
if (booking.status !== "PENDING" || booking.paymentStatus !== "UNPAID") return "ignored";
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: ctx.reason ?? "checkout_expired", now: ctx.now });
await audit(tx, ctx.actor, "booking.expired", "Booking", bookingId, { sessionId, reason: ctx.reason ?? "checkout_expired" });
return "released";
}
export type InstallmentPaidOutcome = { outcome: "paid" | "duplicate" | "needs_review"; fullyPaid: boolean };
/** Folgerate bezahlt (Off-Session-Einzug oder selbst bezahlt per Link). */
export async function applyInstallmentPaid(
tx: Tx,
installmentId: string,
info: PaymentInfo,
ctx: EventContext,
): Promise<InstallmentPaidOutcome> {
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
const booking = await loadLocked(tx, target.bookingId);
const installment = booking.installments.find((i) => i.id === installmentId)!;
if (installment.status === "PAID") {
if (installment.stripePaymentIntentId !== info.paymentIntentId) {
await flagForReview(tx, booking, `Rate ${installment.seq} wurde doppelt bezahlt – bitte eine Zahlung erstatten.`, ctx);
return { outcome: "needs_review", fullyPaid: booking.paidCents >= booking.totalCents };
}
return { outcome: "duplicate", fullyPaid: booking.paidCents >= booking.totalCents };
}
const paidCents = booking.paidCents + info.amountCents;
await tx.installment.update({
where: { id: installmentId },
data: {
status: "PAID",
paidAt: ctx.now,
stripePaymentIntentId: info.paymentIntentId,
stripeChargeId: info.chargeId,
nextAttemptAt: null,
failureCode: null,
},
});
const updated = await tx.booking.update({
where: { id: booking.id },
data: {
paidCents,
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
...(info.paymentMethodId && booking.planType !== "FULL" && !booking.paymentMethodDetachedAt
? { stripePaymentMethodId: info.paymentMethodId }
: {}),
},
include: { installments: { orderBy: { seq: "asc" } } },
});
await audit(tx, ctx.actor, "payment.installment_succeeded", "Installment", installmentId, {
bookingId: booking.id,
seq: installment.seq,
paymentIntentId: info.paymentIntentId,
amountCents: info.amountCents,
paidCents,
});
const problems: string[] = [];
if (info.amountCents !== installment.amountCents) problems.push(`Betrag von Rate ${installment.seq} weicht ab.`);
if (installment.status === "CANCELED" || booking.status === "CANCELED") problems.push(`Rate ${installment.seq} ging nach Stornierung ein.`);
if (paidCents > booking.totalCents) problems.push("Es wurde mehr als der Gesamtpreis gezahlt.");
if (problems.length > 0) {
await flagForReview(tx, updated, problems.join(" "), ctx);
return { outcome: "needs_review", fullyPaid: paidCents >= booking.totalCents };
}
await enqueueEmail(tx, installmentPaidEmail(updated, { ...installment, status: "PAID" }, statusTokenFor(booking.id, ctx.secret)));
return { outcome: "paid", fullyPaid: paidCents >= booking.totalCents };
}
/**
* Off-Session-Einzug fehlgeschlagen. Greift nur, solange die Rate vom Worker
* beansprucht ist (PROCESSING) — so zaehlt ein Fehler nie doppelt, egal ob
* ihn der Worker direkt oder der Webhook zuerst meldet.
*/
export async function applyInstallmentFailed(
tx: Tx,
installmentId: string,
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
ctx: EventContext,
): Promise<"recorded" | "ignored"> {
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
const booking = await loadLocked(tx, target.bookingId);
const installment = booking.installments.find((i) => i.id === installmentId)!;
if (installment.status !== "PROCESSING") return "ignored";
const attempt = installment.attemptCount + 1;
const retry = !info.requiresAction && attempt < MAX_OFF_SESSION_ATTEMPTS;
const updatedInstallment = await tx.installment.update({
where: { id: installmentId },
data: {
status: info.requiresAction ? "REQUIRES_ACTION" : "FAILED",
attemptCount: attempt,
failureCode: info.failureCode,
stripePaymentIntentId: info.paymentIntentId ?? installment.stripePaymentIntentId,
nextAttemptAt: retry ? new Date(ctx.now.getTime() + RETRY_DELAY_DAYS * 86_400_000) : null,
},
});
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installmentId, {
bookingId: booking.id,
seq: installment.seq,
attempt,
failureCode: info.failureCode,
requiresAction: info.requiresAction,
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
});
await enqueueEmail(
tx,
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
);
if (!retry && !info.requiresAction) {
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${attempt} Versuchen nicht eingegangen.`, ctx);
}
return "recorded";
}
/**
* Erstattungsstand einer Zahlung. Stripe liefert den kumulierten Betrag
* (amount_refunded) — absolute Werte machen ungeordnete Events harmlos.
*/
export async function applyRefundState(
tx: Tx,
params: { paymentIntentId: string; amountRefundedCents: number },
ctx: EventContext,
): Promise<"updated" | "ignored"> {
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: params.paymentIntentId } });
if (!target) return "ignored";
const booking = await loadLocked(tx, target.bookingId);
await tx.installment.update({ where: { id: target.id }, data: { refundedCents: params.amountRefundedCents } });
const refundedCents = booking.installments.reduce(
(sum, i) => sum + (i.id === target.id ? params.amountRefundedCents : i.refundedCents),
0,
);
await tx.booking.update({
where: { id: booking.id },
data: { refundedCents, paymentStatus: paymentStatusFor(booking.paidCents, booking.totalCents, refundedCents) },
});
await audit(tx, ctx.actor, "payment.refund_state", "Booking", booking.id, {
paymentIntentId: params.paymentIntentId,
installmentRefundedCents: params.amountRefundedCents,
bookingRefundedCents: refundedCents,
});
return "updated";
}
export async function applyDispute(tx: Tx, paymentIntentId: string, ctx: EventContext): Promise<"flagged" | "ignored"> {
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: paymentIntentId } });
if (!target) return "ignored";
const booking = await loadLocked(tx, target.bookingId);
await flagForReview(tx, booking, `Rückbuchung (Dispute) zu Rate ${target.seq} eröffnet.`, ctx);
return "flagged";
}
export type { FullBooking };