Files
Artista-Filmcamp/src/lib/payment-events.ts
T
Nick SzaunigandClaude Opus 5.5 c1ad9ad2ca feat: Folgeraten über Stripe Billing einziehen
Statt eigener Off-Session-Einzüge durch den Worker legt die Anwendung nach
bezahlter erster Rate einen Stripe Subscription Schedule an. Stripe übernimmt
Einzug, Wiederholungen (Smart Retries), 3-D-Secure-Links und Mahnungen.

- Gateway: Zeitplan anlegen/beenden, Rechnungen lesen; chargeOffSession entfällt
- Webhook: invoice.paid, invoice.payment_failed, invoice.payment_action_required
- Worker: fehlende Zeitpläne anlegen, überfällige Raten abgleichen,
  Zeitpläne stornierter Buchungen beenden
- Storno beendet den Zeitplan und storniert offene Rechnungen
- "Rate bezahlen" führt auf Stripes Rechnungsseite
- Migration: Booking.stripeScheduleId, Installment.stripeInvoiceId

Zeitplan-Form mit Stripe-Testuhr gegen die Test-API geprüft.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 23:52:43 +02:00

417 lines
18 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.
/**
* 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 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));
}
/** Fuer Probleme, die ausserhalb eines Zahlungsereignisses auffallen (z. B. Ratenplan bei Stripe). */
export async function flagBookingForReview(tx: Tx, bookingId: string, reason: string, ctx: EventContext): Promise<void> {
await flagForReview(tx, await loadLocked(tx, bookingId), reason, ctx);
}
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 (ueber ihre Stripe-Rechnung, siehe applyInstallmentInvoicePaid). */
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 };
}
/**
* Ordnet eine Rechnung des Stripe-Zeitplans ihrer Folgerate zu. Stripe
* erzeugt die Rechnungen nacheinander (die naechste erst im naechsten
* Abrechnungszeitraum), deshalb gehoert eine neue Rechnung zur ersten noch
* nicht verknuepften, unbezahlten Rate.
*/
async function installmentForInvoice(tx: Tx, booking: FullBooking, invoiceId: string): Promise<Installment | null> {
const linked = booking.installments.find((i) => i.stripeInvoiceId === invoiceId);
if (linked) return linked;
const next = booking.installments.find((i) => i.seq > 1 && !i.stripeInvoiceId && i.status !== "PAID");
if (!next) return null;
return tx.installment.update({ where: { id: next.id }, data: { stripeInvoiceId: invoiceId } });
}
/** Stripe Billing hat eine Folgerate eingezogen (automatisch oder von der Kundin ueber die Rechnungsseite bezahlt). */
export async function applyInstallmentInvoicePaid(
tx: Tx,
bookingId: string,
invoiceId: string,
info: PaymentInfo,
ctx: EventContext,
): Promise<InstallmentPaidOutcome | "unmatched"> {
const booking = await loadLocked(tx, bookingId);
const installment = await installmentForInvoice(tx, booking, invoiceId);
if (!installment) {
await flagForReview(tx, booking, `Zahlung über Rechnung ${invoiceId} passt zu keiner offenen Rate – bitte prüfen und ggf. erstatten.`, ctx);
return "unmatched";
}
return applyInstallmentPaid(tx, installment.id, info, ctx);
}
/**
* Stripe konnte eine Folgerate nicht einziehen. Stripe versucht es selbst
* erneut (Smart Retries) und schickt bei verlangter Bestaetigung (3-D
* Secure) einen Link. Hier wird nur der Stand festgehalten, die Kundin
* informiert und — wenn Stripe aufgibt — ein Mensch eingeschaltet.
*/
export async function applyInstallmentInvoiceFailed(
tx: Tx,
bookingId: string,
invoice: { id: string; attemptCount: number; nextPaymentAttempt: Date | null },
info: { requiresAction: boolean },
ctx: EventContext,
): Promise<"recorded" | "ignored"> {
const booking = await loadLocked(tx, bookingId);
const installment = await installmentForInvoice(tx, booking, invoice.id);
if (!installment || installment.status === "PAID" || installment.status === "CANCELED") return "ignored";
const status = info.requiresAction ? "REQUIRES_ACTION" : "FAILED";
// Dasselbe Ergebnis desselben Versuchs (doppelt zugestellt oder als
// payment_failed UND payment_action_required gemeldet) nur einmal zaehlen.
if (installment.attemptCount >= invoice.attemptCount && installment.status === status) return "ignored";
const updatedInstallment = await tx.installment.update({
where: { id: installment.id },
data: {
status,
attemptCount: Math.max(invoice.attemptCount, installment.attemptCount),
lastAttemptAt: ctx.now,
failureCode: info.requiresAction ? "authentication_required" : "payment_failed",
nextAttemptAt: invoice.nextPaymentAttempt,
},
});
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installment.id, {
bookingId: booking.id,
seq: installment.seq,
invoiceId: invoice.id,
attempt: invoice.attemptCount,
requiresAction: info.requiresAction,
nextAttemptAt: invoice.nextPaymentAttempt?.toISOString() ?? null,
});
await enqueueEmail(
tx,
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
);
if (!info.requiresAction && !invoice.nextPaymentAttempt) {
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${invoice.attemptCount} 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 };