Files
Artista-Filmcamp/docs/ARCHITEKTUR.md
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

12 KiB
Raw Blame History

Architektur- und Designentscheidungen

Referenzseite

https://www.freedomfilmmaker.de/filmcamp war erreichbar. Ausgewertet wurde eine automatisch erzeugte Zusammenfassung des ausgelieferten HTML, keine vollständige visuelle Analyse. Laut dieser Zusammenfassung ist die Reihenfolge: Hero mit Termin, Ort, Preis und CTA, dann Galerie, Tagesablauf, Unterkunftsgalerie, Platzoptionen mit Preisen, Team-Profile, Abschluss-CTA und Footer mit Rechtlichem. Video-Testimonials waren dort nicht erkennbar. Übernommen wurden nur Prinzipien: Fakten schon im Hero, Unterkunft über Bilder statt Text, Team mit Gesicht, früher und wiederholter Buchungseinstieg, eine persistente Buchungsaktion auf Mobilgeräten. Texte, Marken und Medien wurden nicht übernommen.

Gestaltung

  • Idee: Die Onepage ist als Modemagazin angelegt. Der Hero ist ein Cover, der Masthead „ARTISTA“ überlappt das Leitbild. Schlagzeile und Buchung stehen direkt unter dem Masthead und bleiben im ersten Bildschirm; Termin, Ort und Preis folgen als Cover-Line-Leiste unter dem Bild. Das Cover ist das einzige laute Element, der Rest bleibt ruhig.
  • Farbe: Putz-Weiß #F2ECE3, Papier #FAF7F1, Anthrazit #23211F, Stein #676058, Linie #D8CEC0. Der Akzent ist Pompeji-Rot #8E3B2B statt des üblichen Terrakotta. Kontraste gegen den Grund: 14,6 : 1, 5,3 : 1 und 6,7 : 1.
  • Schrift: Bodoni Moda (Didone) für Display, Hanken Grotesk für Text. Beide werden über next/font selbst ausgeliefert, es gibt keine Verbindung zu Google.
  • Layout: asymmetrisches 12-Spalten-Raster, linksbündig, keine Kartenraster und keine Versal-Labels über Überschriften. Überschrift und Einleitung stehen untereinander (kein „Split-Header“). Listen werden über Abstand gegliedert, nicht über eine Linie unter jeder Zeile. Nummerierung gibt es nur beim beispielhaften Ablauf, weil der eine echte Abfolge ist.
  • Formen: Bedienelemente (Buttons) sind Pillen, Bild- und Tafelflächen haben rechte Winkel. Textlinks sind fein unterstrichen (.link-quiet), der wichtigste Link eines Abschnitts trägt eine rote Unterstreichung (.link-strong).
  • Rezensionen: echte Google-Rezensionen (Places API) als Zitatstrecke: ein führendes Zitat in Bodoni, weitere in zwei Spalten, daneben Durchschnitt und Anzahl. Initialen statt Profilbildern (keine Verbindung zu Google-Servern im Browser), Links nur zu Google-Hosts. Ohne Daten entfällt der Abschnitt; es gibt keine Ersatzstimmen.
  • Bewegung:
    • eine orchestrierte Hero-Eröffnung in reinem CSS
    • Clip-Reveal nur auf Bildern
    • Nav-Indikator
    • Dialog-Übergänge und mobile Leiste über Motion (LazyMotion)
    • prefers-reduced-motion wird überall respektiert, es gibt keine Scroll-Übernahme.
  • Platzhalter: Fehlende Bilder erscheinen als typografische Tafeln: ruhiger Palettenton, feine Papierstruktur, Passepartout und ein angeschnittener Bodoni-Buchstabe aus „ARTISTA“ (im Cover die Jahreszahl). Die früheren Verläufe mit Bogen- und Silhouettenformen wurden ersetzt, weil sie wie generische Füllbilder wirkten; die Tafeln sind eindeutig kein Foto und damit als Platzhalter erkennbar. In Entwicklung und Staging tragen sie zusätzlich das Label „Platzhalter: …“. Leere Video-Slots werden in Produktion ausgeblendet.
  • Hell-Modus: Die Seite hat bewusst nur ein helles Druckbild (Magazin); die dunklen Flächen (Leistungen, Schluss) sind gesetzte Kontrastseiten, kein Dunkelmodus.

Stack und Laufzeit

  • Next.js 16 App Router mit Server Components. Clientseitig sind nur Header, Buchungsdialog (lazy geladen), mobile Leiste, Galerie, Video-Slots und Statusaktionen.
  • Die Ausgabe ist standalone und läuft als Docker-Container unter Nicht-Root. Es gibt keine Vercel-Abhängigkeit.
  • Der Zustand liegt vollständig in PostgreSQL: Buchungen, Reservierungen, Gutscheine, Rate Limits, E-Mail-Ausgang und Stripe-Events. Die App ist dadurch zustandslos und kann mehrere Replikate haben.
  • Die Content-Security-Policy arbeitet mit Nonce und strict-dynamic (src/proxy.ts). Stripe Checkout ist gehostet, deshalb braucht die Seite kein Stripe.js.

ADR-1: Stripe Checkout statt eingebetteter Zahlung

Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, das Speichern der Zahlungsmethode für Folgeraten und dynamische Zahlungsarten. Kartendaten berühren die Anwendung nie, und die CSP bleibt streng. Eine eingebettete Oberfläche hätte keinen Mehrwert gebracht, der den zusätzlichen Integrations- und Sicherheitsaufwand rechtfertigt.

ADR-2: Preisbindung

Regel: Maßgeblich ist der Zeitpunkt des Buchungsstarts. Der dabei serverseitig berechnete Preis gilt, wenn die Zahlung innerhalb der Reservierungszeit abgeschlossen wird (30 Minuten, konfigurierbar, Minimum laut Stripe). Danach verfällt die Reservierung, ein neuer Versuch wird zum dann gültigen Preis berechnet.

Erzwingung:

  • expires_at der Checkout-Session entspricht priceLockedUntil, danach ist die Session bei Stripe nicht mehr bezahlbar.
  • Der Platz bleibt 10 Minuten länger reserviert als die Session läuft, damit eine kurz vor Ablauf abgeschlossene Zahlung ihren Platz sicher behält.
  • Beim Jahreswechsel gilt: Wer am 31.12. um 23:50 Uhr startet, zahlt Early Bird, sofern die Zahlung bis zum angezeigten Ablauf gelingt. Eine Verlängerung gibt es nicht.
  • Kommuniziert wird die Regel im Dialog, in der FAQ und im Stripe-Checkout-Text.

ADR-3: Endliche Ratenpläne

Gewählt (seit 05.10.2026): Stripe Billing zieht die Folgeraten ein. Die erste Rate läuft wie bisher über Stripe Checkout mit setup_future_usage=off_session. Sobald sie bezahlt ist, legt die Anwendung bei Stripe einen Subscription Schedule an (src/lib/installment-billing.ts). Danach kümmert sich Stripe um Einzug, Wiederholungen, 3-D-Secure-Links und Mahnungen; die Anwendung verbucht nur noch die Rechnungs-Events.

Wie die Regel auf Stripe passt: Die Folgeraten sind immer gleich hoch (ein Rest steckt in Rate 1). Der Zeitplan hat deshalb genau einen Preis: Start = Fälligkeit der zweiten Rate, Intervall = Abstand zwischen Rate 2 und 3 in Tagen (bei zwei Raten 1 Tag), Ende = Start + Anzahl Folgeraten × Intervall, end_behavior=cancel. Mit Stripe-Testuhr geprüft: genau eine Rechnung je Folgerate, danach endet das Abonnement ohne weitere Rechnung. Ein Wechsel von Preisen zwischen Phasen erzeugt dagegen keine Rechnung und wurde deshalb verworfen.

Vorher (bis 05.10.2026): Off-Session-PaymentIntents, ausgelöst vom Worker-CronJob. Abgelöst, weil Wiederholungen, Bestätigungslinks und Mahnungen bei Stripe liegen sollen statt beim Betreiber. Invoice Payment Plans (Stripe) kamen nicht in Frage: Private Preview und kein automatischer Einzug.

Sicherungen:

  • Die Raten bleiben exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Jede Stripe-Rechnung wird genau einer Rate zugeordnet (Installment.stripeInvoiceId); Stripe erzeugt die Rechnungen nacheinander, die neue gehört zur ersten noch offenen Rate.
  • Endlichkeit: Der Zeitplan endet nach der letzten Rate von selbst. Eine Zahlung ohne passende offene Rate oder über den Gesamtpreis hinaus landet als „Prüfung nötig“ im Admin. Nach der letzten Rate wird die Zahlungsmethode getrennt.
  • Storno beendet den Zeitplan sofort und storniert offene Rechnungen (void). Scheitert das bei Stripe, holt der Worker es nach; die Zahlungsmethode wird erst danach getrennt.
  • Fehlschlag: Stripe wiederholt nach den Einstellungen unter „Revenue recovery“ (Smart Retries). Die Rate steht auf FAILED mit nächstem Versuch, die Kundin bekommt eine Mail mit Zahlungslink. Gibt Stripe auf, wird die Buchung „Prüfung nötig“.
  • Verlangt die Bank eine Bestätigung, geht die Rate auf REQUIRES_ACTION. Die Kundin zahlt über Stripes Rechnungsseite (hosted_invoice_url), auch mit einer anderen Zahlungsmethode. Eine eigene Checkout-Session gibt es bewusst nicht, sonst würde Stripe parallel weiter einziehen.
  • Idempotenz: Der Zeitplan wird mit Schlüssel schedule:<buchung> angelegt. Doppelte oder ungeordnete Events werden über die Rechnungs-ID und den PaymentIntent nur einmal verbucht.
  • Verlorene Webhooks: Der Worker legt fehlende Zeitpläne an und fragt bei Raten, die seit 6 Stunden fällig und nicht verbucht sind, die Rechnungen des Zeitplans ab.
  • Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel installmentMandateAt.
  • Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters, das Ausfallrisiko bleibt beim Veranstalter. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.

Aufteilung: Ist der Betrag ohne Centrest teilbar, sind alle Raten gleich. Sonst werden die Folgeraten auf volle 50 € abgerundet, der Rest fällt in Rate 1. Das reproduziert alle zwölf Vorgabewerte (Tests).

Termine: Die letzte Rate ist am Stichtag fällig (Anreise minus 30 Tage). Bei drei Raten liegt die zweite in der Mitte zwischen Buchungstag und Stichtag. Der Mindestabstand beträgt 14 Tage, sonst ist die Variante serverseitig deaktiviert. Stripe zieht ab dem Fälligkeitstag um 9:00 Uhr Berliner Zeit ein; liegt zwischen zwei Raten eine Zeitumstellung, verschiebt sich die Uhrzeit der dritten Rate um eine Stunde (Intervall in ganzen Tagen), der Tag bleibt gleich. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.

ADR-4: Kapazität und Gutscheine ohne Überbuchung

  • Jede platzbelegende Transaktion sperrt zuerst die Camp-Zeile (SELECT … FOR UPDATE), dann gegebenenfalls die Gutscheinzeile. Durch die feste Reihenfolge entstehen keine Deadlocks. Zwei Personen bekommen nie denselben letzten Platz oder dieselbe letzte Einlösung (Tests mit 8 bzw. 7 parallelen Buchungen).
  • Belegt sind CONVERTED- und HELD-Reservierungen (verzögerte Zahlung wie SEPA) sowie ACTIVE-Reservierungen, die noch nicht abgelaufen sind. Ein abgelaufener Checkout gibt Platz und Gutschein automatisch frei.
  • Test- und Livebuchungen werden getrennt gezählt (livemode).
  • Öffentlich sind keine Platzzahl, kein Restplatz-Zähler und keine Liste gültiger Codes sichtbar. Abgelehnte Codes bekommen immer dieselbe neutrale Meldung.

ADR-5: Webhooks und Zustände

  • Die Signatur wird gegen den unveränderten Body geprüft (request.text()). Die Event-ID wird in StripeEvent gespeichert, wodurch jedes Event genau einmal verarbeitet wird.
  • Buchungsstatus und Zahlungsstatus sind getrennt. Die Übergänge sind monoton und laufen unter Buchungssperre, so dass ein spätes „expired“ keine bezahlte Buchung aufhebt.
  • Ist nach einer verspäteten Zahlung der Platz inzwischen vergeben, wird nicht überbucht. Die Buchung geht auf „Prüfung nötig“, und Admin und Kundin werden informiert.
  • Die Erfolgsseite zeigt nur den Datenbankstand. Bezahlt markieren darf nur der Webhook oder der Worker-Abgleich, der Stripe selbst abfragt.
  • E-Mails laufen über einen transaktionalen Postausgang: gleiche Transaktion wie die Statusänderung, Versand danach, Wiederholung durch den Worker.

ADR-6: Verkaufsfreigabe

src/lib/sales-gate.ts wird von jedem Checkout-Endpunkt aufgerufen.

  • Livemodus (sk_live_): Verkauf nur mit laufender echter Preisphase, eingeschaltetem Live-Verkauf und allen elf Bestätigungen.
  • Testmodus: Checkout ist ohne Freigaben möglich, die Preisphase lässt sich simulieren.
  • Schutz vor Fehlkonfiguration: Ein Live-Schlüssel außerhalb von APP_ENV=production bricht mit einem Fehler ab.

ADR-7: Adminbereich

Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog.