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]>
417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
/**
|
||
* 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 };
|