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]>
This commit is contained in:
1 parent
bcf0f5c937
commit
c1ad9ad2ca
18 files changed
+800
-555
No files matched your search
+65
-28
@@ -31,9 +31,6 @@ import {
|
||||
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 = {
|
||||
@@ -66,6 +63,11 @@ async function flagForReview(tx: Tx, booking: Booking, reason: string, ctx: Even
|
||||
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. */
|
||||
@@ -230,7 +232,7 @@ export async function applyCheckoutExpired(
|
||||
|
||||
export type InstallmentPaidOutcome = { outcome: "paid" | "duplicate" | "needs_review"; fullyPaid: boolean };
|
||||
|
||||
/** Folgerate bezahlt (Off-Session-Einzug oder selbst bezahlt per Link). */
|
||||
/** Folgerate bezahlt (ueber ihre Stripe-Rechnung, siehe applyInstallmentInvoicePaid). */
|
||||
export async function applyInstallmentPaid(
|
||||
tx: Tx,
|
||||
installmentId: string,
|
||||
@@ -294,47 +296,82 @@ export async function applyInstallmentPaid(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function applyInstallmentFailed(
|
||||
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,
|
||||
installmentId: string,
|
||||
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
|
||||
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 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 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 attempt = installment.attemptCount + 1;
|
||||
const retry = !info.requiresAction && attempt < MAX_OFF_SESSION_ATTEMPTS;
|
||||
const updatedInstallment = await tx.installment.update({
|
||||
where: { id: installmentId },
|
||||
where: { id: installment.id },
|
||||
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,
|
||||
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", installmentId, {
|
||||
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installment.id, {
|
||||
bookingId: booking.id,
|
||||
seq: installment.seq,
|
||||
attempt,
|
||||
failureCode: info.failureCode,
|
||||
invoiceId: invoice.id,
|
||||
attempt: invoice.attemptCount,
|
||||
requiresAction: info.requiresAction,
|
||||
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
|
||||
nextAttemptAt: invoice.nextPaymentAttempt?.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);
|
||||
if (!info.requiresAction && !invoice.nextPaymentAttempt) {
|
||||
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${invoice.attemptCount} Versuchen nicht eingegangen.`, ctx);
|
||||
}
|
||||
return "recorded";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user