Files
Artista-Filmcamp/src/app/api/booking/status-action/route.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

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 });
}
}
}