/** * 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 { 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 { 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 { 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 { 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 { 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 { 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 };