# 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. - „Prüfung nötig“ hält den Plan an: Gerät eine Buchung in Prüfung (z. B. Rückbuchung, Doppelzahlung), wird der Stripe-Plan beendet, offene Rechnungen werden storniert und unbezahlte Folgeraten gehen zurück auf `PENDING`. Bestätigt ein Mensch die Buchung, legt der Worker einen neuen Plan für die offenen Raten an (Start an der Fälligkeit oder, falls vorbei, am nächsten 9-Uhr-Termin; letzte Rate bleibt am Stichtag). So zieht Stripe wie früher der Worker nur bei bestätigten Buchungen ein. - Idempotenz: Vor dem Anlegen sucht die Anwendung bei Stripe nach einem laufenden Plan dieser Buchung (Metadaten `bookingId`) und übernimmt ihn, auch wenn das Speichern der ID früher scheiterte. Angelegt wird mit Schlüssel `schedule:::`; die Generation steigt mit jedem Anhalten, sonst lieferte Stripe binnen 24 Stunden den alten, beendeten Plan zurück. 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 eingeschaltetem Live-Verkauf und allen elf Bestätigungen. Sind alle erteilt, startet der Verkauf sofort, auch vor dem 01.11.2026; bis dahin gilt der Early-Bird-Preis (seit 06.10.2026). - **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.