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]>
57 lines
2.6 KiB
TypeScript
57 lines
2.6 KiB
TypeScript
/**
|
|
* Aktionen der Kundin auf ihrer Statusseite. Zugang nur ueber das
|
|
* Statustoken aus Bestaetigungsmail bzw. Stripe-Ruecksprung.
|
|
*/
|
|
import { z } from "zod";
|
|
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
|
import { abandonCheckout, installmentPaymentUrl } from "@/lib/checkout";
|
|
import { getDb } from "@/lib/db";
|
|
import { getGateway } from "@/lib/payment-gateway";
|
|
import { RATE_LIMITS } from "@/lib/rate-limit";
|
|
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
|
|
|
const schema = z.discriminatedUnion("action", [
|
|
z.object({ action: z.literal("pay_installment"), token: z.string(), installmentId: z.string().min(1).max(64) }),
|
|
z.object({ action: z.literal("resume_checkout"), token: z.string() }),
|
|
z.object({ action: z.literal("abandon"), token: z.string() }),
|
|
]);
|
|
|
|
export async function POST(request: Request) {
|
|
const blocked = checkSameOrigin(request);
|
|
if (blocked) return blocked;
|
|
const limited = await rateLimited(request, RATE_LIMITS.statusAction);
|
|
if (limited) return limited;
|
|
const body = await readJson(request, schema);
|
|
if (!body.ok) return body.response;
|
|
|
|
const notFound = fail(404, { code: "NOT_FOUND", message: "Diese Buchung wurde nicht gefunden." });
|
|
if (!isWellFormedStatusToken(body.data.token)) return notFound;
|
|
const db = getDb();
|
|
const booking = await db.booking.findUnique({ where: { statusTokenHash: sha256Hex(body.data.token) } });
|
|
if (!booking) return notFound;
|
|
|
|
const gateway = getGateway();
|
|
if (booking.livemode !== (gateway.mode === "live")) return notFound;
|
|
const now = new Date();
|
|
|
|
switch (body.data.action) {
|
|
case "pay_installment": {
|
|
const result = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId });
|
|
if (result.ok) return ok({ url: result.url });
|
|
return result.error === "NOT_PAYABLE"
|
|
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
|
: fail(502, { code: "PAYMENT_PROVIDER", message: "Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuche es später erneut." });
|
|
}
|
|
case "resume_checkout": {
|
|
if (booking.status === "PENDING" && booking.paymentStatus === "UNPAID" && booking.stripeCheckoutUrl && booking.priceLockedUntil > now) {
|
|
return ok({ url: booking.stripeCheckoutUrl });
|
|
}
|
|
return fail(409, { code: "EXPIRED", message: "Die Reservierung ist abgelaufen. Bitte starte eine neue Buchung." });
|
|
}
|
|
case "abandon": {
|
|
await abandonCheckout(db, gateway, booking, { now });
|
|
return ok({ abandoned: true });
|
|
}
|
|
}
|
|
}
|