Compare commits
30
Commits
84c8f2f32b
..
main
No files matched your search
+3
-1
@@ -26,7 +26,9 @@ BETTER_AUTH_SECRET=<anderer-zufaelliger-wert-mindestens-32-zeichen>
|
|||||||
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
||||||
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
||||||
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
|
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
|
||||||
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds.
|
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds und
|
||||||
|
# fuer die Folgeraten (Stripe Billing) zusaetzlich Products, Subscription
|
||||||
|
# Schedules, Subscriptions, Invoices (docs/BETRIEB.md, Abschnitt 4).
|
||||||
STRIPE_SECRET_KEY=<sk_test_…>
|
STRIPE_SECRET_KEY=<sk_test_…>
|
||||||
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
||||||
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
||||||
|
|||||||
@@ -7,14 +7,101 @@ name: Docker-Images bauen und veroeffentlichen
|
|||||||
#
|
#
|
||||||
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
||||||
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
|
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
|
||||||
|
#
|
||||||
|
# Qualitaets-Gate: Images entstehen nur, wenn lint (inkl. audit), typecheck
|
||||||
|
# und test gruen sind. Die Pruefungen laufen auf "ci-isolated" (Jobs ohne
|
||||||
|
# Docker-Socket und ohne Clusternetz, Runner-Doku im VyCept-Repo,
|
||||||
|
# docs/BETRIEB.md 11b); nur der Image-Build braucht "docker-builder".
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ein neuer Push bricht den ueberholten Lauf ab, statt ihn zu Ende zu rechnen.
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
# Bekannte Luecken in Laufzeit-Abhaengigkeiten (ab "high") brechen den Lauf ab.
|
||||||
|
- run: pnpm audit --prod --audit-level high
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 15
|
||||||
|
# embedded-postgres startet nicht als root; deshalb ein Datenbank-Service
|
||||||
|
# (gleiche Version wie im Cluster), genutzt ueber TEST_DATABASE_URL
|
||||||
|
# (tests/support/global-setup.ts).
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.6-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: artista_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U test" --health-interval 5s --health-retries 10
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/artista_test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
runs-on: docker-builder
|
runs-on: docker-builder
|
||||||
env:
|
env:
|
||||||
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
# ARTISTA MODELCAMP 2027 – Buchungswebsite
|
# ARTISTA MODELCAMP 2027 – Buchungswebsite
|
||||||
|
|
||||||
Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, geschützter Adminbereich mit Zwei-Faktor-Anmeldung, Docker- und RKE2-Deployment.
|
Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, Kontaktformular für ein Erstgespräch, geschützter Adminbereich mit Zwei-Faktor-Anmeldung, Docker- und RKE2-Deployment.
|
||||||
|
|
||||||
**Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Der Live-Verkauf ist serverseitig gesperrt, bis alle Inhalte und Freigaben aus [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) im Adminbereich bestätigt sind.
|
**Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Die Seite läuft im Cluster unter https://modelcamp.artistasphotography.de (Namespace `artista`). Der Live-Verkauf ist serverseitig gesperrt, bis alle Inhalte und Freigaben aus [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) im Adminbereich bestätigt sind.
|
||||||
|
|
||||||
|
## Projektstand
|
||||||
|
|
||||||
|
Was gebaut, geprüft und ausgerollt ist und was noch offen ist, steht im Übersichts-Issue [#33 „Projektstand und Übersicht“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/33). Aufgaben, die nur der Inhaber erledigen kann (Freigaben, Rechtstexte, Secrets, Rollouts), sammelt [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32). Jedes Arbeitspaket hat ein eigenes Issue.
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Design |
|
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Kontaktformular, Design |
|
||||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, CI, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
||||||
|
| [k8s/README.md](k8s/README.md) | Manifeste Schritt für Schritt anwenden, neues Image ausrollen, Fehlersuche |
|
||||||
|
| [docs/MEDIEN.md](docs/MEDIEN.md) | Dateinamen für Logo, Headerbild, Intro-Video, Porträts und Reels; Medien aufbereiten |
|
||||||
| [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben |
|
| [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben |
|
||||||
| [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen |
|
| [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen |
|
||||||
|
|
||||||
@@ -33,7 +39,7 @@ Stripe-Testbuchung mit Webhooks lokal:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
stripe listen --forward-to localhost:3000/api/stripe/webhook # gibt das whsec_… für STRIPE_WEBHOOK_SECRET aus
|
stripe listen --forward-to localhost:3000/api/stripe/webhook # gibt das whsec_… für STRIPE_WEBHOOK_SECRET aus
|
||||||
pnpm worker # Reservierungen, Raten, E-Mails (im Cluster: CronJob)
|
pnpm worker # Reservierungen, Raten-Abgleich, E-Mails (im Cluster: CronJob)
|
||||||
```
|
```
|
||||||
|
|
||||||
Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E-Mails landen mit `MAIL_TRANSPORT=file` als `.eml` in `.mail-outbox/` (oder in Mailpit unter http://localhost:8025).
|
Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E-Mails landen mit `MAIL_TRANSPORT=file` als `.eml` in `.mail-outbox/` (oder in Mailpit unter http://localhost:8025).
|
||||||
@@ -46,9 +52,14 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
|
|||||||
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
|
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
|
||||||
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
||||||
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
|
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
|
||||||
|
| `pnpm db:dev` | Eingebettetes PostgreSQL für die Entwicklung (Port 54329) |
|
||||||
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
|
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
|
||||||
|
| `pnpm db:seed` | Nur Entwicklung: Early Bird simulieren, Test-Gutschein `TEST-STAMMMODEL` |
|
||||||
| `pnpm worker` | Einmaliger Worker-Lauf |
|
| `pnpm worker` | Einmaliger Worker-Lauf |
|
||||||
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
|
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
|
||||||
|
| `pnpm media:optimize <ordner>` | Videos und große Bilder vor dem Hochladen aufbereiten (siehe docs/MEDIEN.md) |
|
||||||
|
|
||||||
|
Bei jedem Push auf `main` prüft die CI (`.gitea/workflows/docker-build.yml`) zuerst `lint` samt `pnpm audit`, `typecheck` und `test`; erst wenn alle drei grün sind, baut sie die beiden Images. Die Playwright-Tests laufen nicht in der CI.
|
||||||
|
|
||||||
## Wo was gepflegt wird
|
## Wo was gepflegt wird
|
||||||
|
|
||||||
@@ -59,4 +70,5 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
|
|||||||
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben).
|
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben).
|
||||||
- **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md).
|
- **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md).
|
||||||
- **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`.
|
- **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`.
|
||||||
|
- **Empfänger des Formulars „Erstgespräch“:** die Veranstalter-E-Mail aus dem Adminbereich, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ist keine von beiden gesetzt, lehnt das Formular Anfragen ab.
|
||||||
- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:18.1-alpine
|
image: postgres:18.6-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: artista
|
POSTGRES_USER: artista
|
||||||
POSTGRES_PASSWORD: artista-dev
|
POSTGRES_PASSWORD: artista-dev
|
||||||
|
|||||||
+28
-11
@@ -44,22 +44,27 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
|
|||||||
|
|
||||||
## ADR-3: Endliche Ratenpläne
|
## ADR-3: Endliche Ratenpläne
|
||||||
|
|
||||||
**Gewählt:** Die erste Rate läuft über Stripe Checkout mit `setup_future_usage=off_session`. Die Folgeraten sind einzelne Off-Session-PaymentIntents, die ein idempotenter Kubernetes-CronJob am Fälligkeitstag um 9:00 Uhr Berliner Zeit auslöst.
|
**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.
|
||||||
|
|
||||||
**Verworfen: Subscription Schedules.** Sie sind laut Stripe-Doku das Standardmuster für Ratenzahlung, rechnen aber in festen Intervallen (z. B. monatlich). Die geforderte Regel „letzte Rate spätestens 30 Tage vor Anreise“ und eine vom Buchungstag abhängige Mitte lassen sich damit nicht sauber abbilden. Außerdem wäre es formal ein Abonnement.
|
**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.
|
||||||
|
|
||||||
**Gründe für den gewählten Weg:**
|
**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.
|
||||||
- Die Raten sind exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Beträge wie 800 + 700 + 700 € sind direkt abbildbar.
|
|
||||||
- Endlichkeit ist dreifach gesichert: Es gibt maximal drei Ratenzeilen, jeder Einzug prüft `paid + Rate ≤ Gesamt`, und nach der letzten Rate wird die Zahlungsmethode bei Stripe getrennt.
|
**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:<buchung>:<generation>:<start>`; 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`.
|
- Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel `installmentMandateAt`.
|
||||||
- Verlangt die Bank eine Authentifizierung, geht die Rate auf `REQUIRES_ACTION`, und die Kundin bekommt per E-Mail einen Link und zahlt über eine eigene Checkout-Session.
|
- 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.
|
||||||
- Bei Fehlschlag gibt es bis zu drei Versuche im Abstand von drei Tagen, danach „Prüfung nötig“ im Admin.
|
|
||||||
- Idempotenzschlüssel `offsession:<rate>:<versuch>` verhindern Doppelabbuchungen, auch nach Netzwerkfehlern oder Worker-Abbrüchen.
|
|
||||||
- Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters. 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).
|
**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. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.
|
**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
|
## ADR-4: Kapazität und Gutscheine ohne Überbuchung
|
||||||
|
|
||||||
@@ -80,10 +85,22 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
|
|||||||
|
|
||||||
`src/lib/sales-gate.ts` wird von jedem Checkout-Endpunkt aufgerufen.
|
`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.
|
- **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.
|
- **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.
|
- **Schutz vor Fehlkonfiguration:** Ein Live-Schlüssel außerhalb von `APP_ENV=production` bricht mit einem Fehler ab.
|
||||||
|
|
||||||
## ADR-7: Adminbereich
|
## 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.
|
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.
|
||||||
|
|
||||||
|
## ADR-8: Kontaktformular „Erstgespräch“ (seit 06.10.2026)
|
||||||
|
|
||||||
|
- Eigener Abschnitt nach den Fragen (Anker `#erstgespraech`, `POST /api/contact`). Pflicht sind Name, E-Mail, Telefon (seit 07.10.2026, gleiche Regel wie bei der Buchung) und die Bestätigung der Datenschutzhinweise; Erreichbarkeit und Nachricht sind optional.
|
||||||
|
- Kein eigenes Datenmodell: Die Anfrage geht als E-Mail über den transaktionalen Postausgang an die Veranstalter-E-Mail aus dem Admin, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ohne Empfänger lehnt das Formular ab.
|
||||||
|
- Bewusst keine automatische Bestätigungsmail an die angegebene Adresse, damit das Formular nicht zum Versand an Dritte missbraucht werden kann.
|
||||||
|
- Schutz wie bei der Buchung: nur JSON von der eigenen Seite, Rate Limit 5 Anfragen je Stunde und IP, Honeypot-Feld gegen Bots, doppeltes Absenden in derselben Minute ergibt eine Mail.
|
||||||
|
|
||||||
|
## ADR-9: Client-IP und Rate Limits (seit 12.10.2026)
|
||||||
|
|
||||||
|
- Rate Limits liegen in PostgreSQL (`RateLimitBucket`, gehashter IP-Schlüssel, Aufräumen nach einem Tag durch den Worker). IPv6-Adressen zählen je /64-Netz.
|
||||||
|
- Die Client-IP ist die Gegenstelle laut Traefik (letzter `X-Forwarded-For`-Eintrag). `CF-Connecting-IP` gilt nur als öffentliche Unicast-Adresse, `X-Real-IP` ohne `X-Forwarded-For` nur außerhalb der Produktion. Better Auth bekommt die IP ausschließlich über den serverseitig gesetzten Header `x-artista-client-ip`; ein vom Browser mitgeschickter Wert wird verworfen.
|
||||||
+32
-16
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
## 1. Images bauen
|
## 1. Images bauen
|
||||||
|
|
||||||
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“ beide Images und pusht sie in die Gitea-Registry:
|
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` läuft bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“. Zuerst prüfen drei Jobs auf dem isolierten Runner (`ci-isolated`, ohne Docker-Socket) den Code: `lint` (inklusive `pnpm audit --prod --audit-level high`), `typecheck` und `test` (mit PostgreSQL 18.6 als Service). Nur wenn alle drei grün sind, baut `build-and-push` auf dem Runner `docker-builder` beide Images und pusht sie in die Gitea-Registry:
|
||||||
|
|
||||||
- `main` ergibt die Tags `latest` und `sha-<kurz>`
|
- `main` ergibt die Tags `latest` und `sha-<kurz>`
|
||||||
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
||||||
|
|
||||||
Einmalig einrichten:
|
Einmalig einrichten:
|
||||||
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
|
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
|
||||||
- einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain)
|
- Runner mit den Labels `ci-isolated` (Prüfungen) und `docker-builder` (Image-Build) bereitstellen; im EnCry-Cluster sind beide vorhanden
|
||||||
|
|
||||||
Die Manifeste in `k8s/` nutzen den Tag `latest`. Für feste Versionen dort `:latest` durch z. B. `:0.1.0` ersetzen (`git tag v0.1.0 && git push origin v0.1.0`).
|
Die Manifeste in `k8s/` sind auf einen CI-Build gepinnt (`sha-<commit>`, Platzhalter `sha-REPLACE_ME` im Repo), nicht auf `latest`. Ausgerollt wird mit `kubectl set image`, siehe Abschnitt 3 und [k8s/README.md](../k8s/README.md).
|
||||||
|
|
||||||
|
Scheitert `build-and-push` beim Push mit `500 … cannot allocate memory`, lag das bisher an der Gitea-Registry, wenn gleichzeitig ein anderes Repo pushte. Dann den Lauf neu starten, sobald der andere Build fertig ist.
|
||||||
|
|
||||||
**Manuell:**
|
**Manuell:**
|
||||||
|
|
||||||
@@ -26,27 +28,35 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
|
|||||||
## 2. Einmalig einrichten
|
## 2. Einmalig einrichten
|
||||||
|
|
||||||
**Was ausgerollt wird:** StatefulSet PostgreSQL, Services (App, Postgres), Ingress, App-Deployment (mit Migration als initContainer), zwei SMB-Volumes (Postgres-Daten, Medien) und zusätzlich:
|
**Was ausgerollt wird:** StatefulSet PostgreSQL, Services (App, Postgres), Ingress, App-Deployment (mit Migration als initContainer), zwei SMB-Volumes (Postgres-Daten, Medien) und zusätzlich:
|
||||||
- der Worker-CronJob, nötig für Folgeraten, Reservierungsfreigabe und Mail-Wiederholung
|
- der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung
|
||||||
- ConfigMap
|
- ConfigMap
|
||||||
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
|
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
|
||||||
|
- der Sicherungs-CronJob `artista-db-backup` (täglicher `pg_dump`, Abschnitt 7)
|
||||||
|
|
||||||
Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert.
|
**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `09-db-backup-cronjob`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es.
|
||||||
|
|
||||||
**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `08-networkpolicy`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es.
|
Im EnCry-Cluster liegen die SMB-Freigaben auf der Hetzner Storage Box, nicht auf einem lokalen NAS; „NAS“ meint im Folgenden diese Freigabe. Im Namespace `artista` läuft außerdem Artista-Telefonie (eigenes Repo `Artista-TeleTracking`). Sie nutzt eine eigene Datenbank `telefonie` in derselben PostgreSQL-Instanz `artista-modelcamp-postgres`. Wer die Instanz oder den Namespace anfasst, trifft beide Anwendungen.
|
||||||
|
|
||||||
Zu PostgreSQL:
|
Zu PostgreSQL:
|
||||||
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
|
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
|
||||||
- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
|
- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
|
||||||
- **Sicherung:** Die NAS-Sicherung ist für die laufende Datenbank nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7).
|
- **Sicherung:** Eine Kopie der Freigabe ist für die laufende Datenbank nur mit **atomaren Snapshots** (Btrfs/ZFS) konsistent. Deshalb zieht der CronJob `artista-db-backup` jede Nacht einen `pg_dump` (Abschnitt 7).
|
||||||
|
- **Nach SMB-Unterbrechungen** melden lang laufende Postgres-Prozesse gelegentlich `could not seek to end of file … Bad file descriptor`. Abhilfe: `kubectl -n artista rollout restart sts/artista-modelcamp-postgres` (fährt sauber herunter).
|
||||||
|
|
||||||
## 3. Update ausrollen
|
## 3. Update ausrollen
|
||||||
|
|
||||||
|
App, Migration und Worker immer mit demselben Tag `sha-<commit>` aus der CI ausrollen (genauer Befehl in [k8s/README.md](../k8s/README.md), Abschnitt „Bei einem neuen Image“):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n artista rollout restart deploy/artista-modelcamp-app
|
TAG=sha-<commit>
|
||||||
|
kubectl -n artista set image deployment/artista-modelcamp-app migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
|
||||||
|
kubectl -n artista set image cronjob/artista-modelcamp-worker worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
|
||||||
kubectl -n artista rollout status deploy/artista-modelcamp-app
|
kubectl -n artista rollout status deploy/artista-modelcamp-app
|
||||||
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
|
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ein `rollout restart` allein holt kein neues Image, weil die Manifeste auf einen festen Tag zeigen.
|
||||||
|
|
||||||
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
|
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
|
||||||
|
|
||||||
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
|
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
|
||||||
@@ -55,9 +65,13 @@ Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, spät
|
|||||||
|
|
||||||
- **Schlüssel:** Im Test `sk_test_…`, live `sk_live_…` (nur mit `APP_ENV=production`). Empfohlen ist ein eingeschränkter Schlüssel mit Schreibrechten auf Customers, Checkout Sessions, PaymentIntents, PaymentMethods und Refunds.
|
- **Schlüssel:** Im Test `sk_test_…`, live `sk_live_…` (nur mit `APP_ENV=production`). Empfohlen ist ein eingeschränkter Schlüssel mit Schreibrechten auf Customers, Checkout Sessions, PaymentIntents, PaymentMethods und Refunds.
|
||||||
- **Webhook-Endpunkt:** `https://<domain>/api/stripe/webhook`. Diese Events abonnieren:
|
- **Webhook-Endpunkt:** `https://<domain>/api/stripe/webhook`. Diese Events abonnieren:
|
||||||
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`.
|
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `invoice.paid`, `invoice.payment_failed`, `invoice.payment_action_required`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`.
|
||||||
Das Signing Secret gehört als `STRIPE_WEBHOOK_SECRET` ins Secret. Test- und Live-Endpunkt haben getrennte Secrets.
|
Das Signing Secret gehört als `STRIPE_WEBHOOK_SECRET` ins Secret. Test- und Live-Endpunkt haben getrennte Secrets.
|
||||||
- **Zahlungsarten** (Dashboard → Zahlungsmethoden): Für Ratenzahlung nur wiederverwendbare Methoden aktivieren (Karten, gegebenenfalls SEPA-Lastschrift). Checkout blendet bei Ratenzahlung ohnehin nur Methoden ein, die `setup_future_usage` unterstützen.
|
- **Zahlungsarten** (Dashboard → Zahlungsmethoden): Für Ratenzahlung nur wiederverwendbare Methoden aktivieren (Karten, gegebenenfalls SEPA-Lastschrift). Checkout blendet bei Ratenzahlung ohnehin nur Methoden ein, die `setup_future_usage` unterstützen.
|
||||||
|
- **Folgeraten (Stripe Billing):** Stripe zieht die Raten 2 und 3 über einen Subscription Schedule ein (docs/ARCHITEKTUR.md, ADR-3). Im Dashboard unter Billing → Einstellungen festlegen:
|
||||||
|
- **Revenue recovery → Retries:** Smart Retries an (z. B. 4 Versuche innerhalb von 2 Wochen). Nach dem letzten Versuch: Abonnement kündigen. Die Buchung erscheint dann als „Prüfung nötig“.
|
||||||
|
- **E-Mails an Kunden:** „Zahlung fehlgeschlagen“ und „Bestätigung für 3-D Secure erforderlich“ einschalten, damit Stripe selbst mahnt und Bestätigungslinks schickt. Die App schickt zusätzlich eine eigene deutschsprachige Mail mit Link zur Statusseite.
|
||||||
|
- Der eingeschränkte Schlüssel braucht zusätzlich Schreibrechte für Products, Subscription Schedules, Subscriptions und Invoices.
|
||||||
- **Test lokal:** `stripe listen --forward-to localhost:3000/api/stripe/webhook`
|
- **Test lokal:** `stripe listen --forward-to localhost:3000/api/stripe/webhook`
|
||||||
- **Sicherheitsnetz:** Der Worker gleicht bei abgelaufenen Reservierungen und hängenden Einzügen immer mit Stripe ab. Ein verlorener Webhook führt so nicht zu verlorenen Zahlungen.
|
- **Sicherheitsnetz:** Der Worker gleicht bei abgelaufenen Reservierungen und hängenden Einzügen immer mit Stripe ab. Ein verlorener Webhook führt so nicht zu verlorenen Zahlungen.
|
||||||
|
|
||||||
@@ -69,8 +83,10 @@ Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, spät
|
|||||||
|
|
||||||
Der CronJob `artista-modelcamp-worker` läuft alle 10 Minuten und kümmert sich um:
|
Der CronJob `artista-modelcamp-worker` läuft alle 10 Minuten und kümmert sich um:
|
||||||
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
|
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
|
||||||
- hängende Einzüge
|
- hängende erste Zahlungen (SEPA)
|
||||||
- fällige Raten
|
- fehlende Stripe-Ratenpläne anlegen (z. B. nach Bestätigung einer geprüften Buchung)
|
||||||
|
- Abgleich überfälliger Raten mit den Stripe-Rechnungen (verlorene Webhooks)
|
||||||
|
- Ratenpläne stornierter Buchungen beenden, falls das beim Storno scheiterte
|
||||||
- das Trennen der Zahlungsmethode nach der letzten Rate
|
- das Trennen der Zahlungsmethode nach der letzten Rate
|
||||||
- den E-Mail-Versand
|
- den E-Mail-Versand
|
||||||
- das Aufräumen der Rate Limits
|
- das Aufräumen der Rate Limits
|
||||||
@@ -79,10 +95,10 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
|
|||||||
|
|
||||||
## 7. Sicherung und Wiederherstellung
|
## 7. Sicherung und Wiederherstellung
|
||||||
|
|
||||||
- **Sicherung:** Die NAS-Sicherung der SMB-Freigabe übernimmt das Speichersystem. Für PostgreSQL gilt das nur mit atomaren Snapshots, siehe Abschnitt 2. Im Cluster läuft kein Backup-Job.
|
- **Täglicher Dump im Cluster:** Der CronJob `artista-db-backup` (`k8s/09-db-backup-cronjob.yaml`) läuft um 02:25 Uhr (Zeitzone des Clusters) und schreibt `pg_dump -Fc` der Datenbank `artista` in die PVC `artista-db-backup` (StorageClass `smb-backup`, Ordner `Backups/artista/` auf der Storage Box). Aufbewahrung: 14 Tage täglich (`daily/`), 8 Wochen sonntags (`weekly/`). Die Datenbank `telefonie` derselben Instanz ist darin nicht enthalten. Stand prüfen: `kubectl -n artista get cronjob artista-db-backup` und `kubectl -n artista get jobs | grep artista-db-backup`.
|
||||||
- **Logischer Dump** (empfohlen vor Updates und vor dem Live-Verkauf):
|
- **Logischer Dump von Hand** (zusätzlich vor Updates und vor dem Live-Verkauf):
|
||||||
```bash
|
```bash
|
||||||
POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-modelcamp-postgres -o name | head -1)
|
POD=$(kubectl -n artista get pod -l app=artista-modelcamp-postgres -o name | head -1)
|
||||||
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
|
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
|
||||||
```
|
```
|
||||||
- **Wiederherstellen:**
|
- **Wiederherstellen:**
|
||||||
@@ -90,8 +106,8 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
|
|||||||
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
|
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
|
||||||
3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
|
3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
|
||||||
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
|
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
|
||||||
- **Medien:** liegen im SMB-Ordner `modelcamp-media` und sind in der NAS-Sicherung enthalten.
|
- **Medien:** liegen im SMB-Ordner `modelcamp-media`; gesichert sind sie nur über die Sicherung der Freigabe selbst.
|
||||||
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace.
|
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace. Das ist noch nicht geschehen.
|
||||||
|
|
||||||
## 8. Stornierung und Erstattung
|
## 8. Stornierung und Erstattung
|
||||||
|
|
||||||
|
|||||||
+25
-3
@@ -4,7 +4,7 @@ Logo, Headerbild, Porträts und Reels liegen im Medienverzeichnis. Im Cluster is
|
|||||||
|
|
||||||
Lege eine Datei unter dem passenden Namen dort ab, dann erscheint sie beim nächsten Seitenaufruf. Neustart und Deployment sind nicht nötig. Fehlt eine Datei, bleibt der Platzhalter oder das Standardbild sichtbar.
|
Lege eine Datei unter dem passenden Namen dort ab, dann erscheint sie beim nächsten Seitenaufruf. Neustart und Deployment sind nicht nötig. Fehlt eine Datei, bleibt der Platzhalter oder das Standardbild sichtbar.
|
||||||
|
|
||||||
Dateinamen bitte genau so schreiben: alles klein, ohne Leerzeichen und Umlaute. Bei mehreren Endungen nimmt die Seite die erste, die sie findet.
|
Dateinamen bitte so schreiben: ohne Leerzeichen und Umlaute. Gross- und Kleinschreibung ist egal (`Reel-1.JPG` wird auch erkannt). Bei mehreren Endungen nimmt die Seite die erste, die sie findet.
|
||||||
|
|
||||||
## Logo
|
## Logo
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Dateinamen bitte genau so schreiben: alles klein, ohne Leerzeichen und Umlaute.
|
|||||||
| `logo.svg` (oder `.png`, `.webp`) | Dunkles Logo für helle Flächen: Kopfzeile nach dem Scrollen, Fußbereich | SVG bevorzugt, PNG mit transparentem Hintergrund |
|
| `logo.svg` (oder `.png`, `.webp`) | Dunkles Logo für helle Flächen: Kopfzeile nach dem Scrollen, Fußbereich | SVG bevorzugt, PNG mit transparentem Hintergrund |
|
||||||
| `logo-hell.svg` (oder `.png`, `.webp`) | Helles bzw. weißes Logo über dem Headerbild | Fehlt es, wird `logo.*` verwendet |
|
| `logo-hell.svg` (oder `.png`, `.webp`) | Helles bzw. weißes Logo über dem Headerbild | Fehlt es, wird `logo.*` verwendet |
|
||||||
|
|
||||||
Das Logo wird 32 bis 40 px hoch angezeigt. Ein querformatiges Logo ohne Rand wirkt am besten.
|
Das Logo wird über dem Headerbild bis 176 px hoch angezeigt, nach dem Scrollen 80 px. Die Seite misst selbst, ob eine Datei hell oder dunkel ist, und setzt sie auf den passenden Untergrund. Liegt nur eine helle Fassung vor, wird sie auf hellen Flächen automatisch abgedunkelt (und umgekehrt). Große PNGs werden verkleinert ausgeliefert.
|
||||||
|
|
||||||
## Headerbild
|
## Headerbild
|
||||||
|
|
||||||
@@ -22,6 +22,14 @@ Das Logo wird 32 bis 40 px hoch angezeigt. Ein querformatiges Logo ohne Rand wir
|
|||||||
| `header.jpg` (oder `.webp`, `.png`) | Bild über die ganze Breite oben | Querformat, mindestens 2400 px breit. Ohne Datei erscheint die Luftaufnahme der Villa |
|
| `header.jpg` (oder `.webp`, `.png`) | Bild über die ganze Breite oben | Querformat, mindestens 2400 px breit. Ohne Datei erscheint die Luftaufnahme der Villa |
|
||||||
| `header.mp4` (optional) | Video statt Standbild, läuft stumm in Schleife | Kurz halten (unter 15 s, unter 10 MB). `header.jpg` dient als Vorschaubild |
|
| `header.mp4` (optional) | Video statt Standbild, läuft stumm in Schleife | Kurz halten (unter 15 s, unter 10 MB). `header.jpg` dient als Vorschaubild |
|
||||||
|
|
||||||
|
## Video unter dem Headerbild
|
||||||
|
|
||||||
|
| Datei | Wofür | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| `intro.mp4` | Video direkt unter dem Headerbild, startet erst nach Klick | Quer- oder Hochformat. Ohne Datei entfällt der Abschnitt |
|
||||||
|
| `intro.jpg` (oder `.webp`, `.png`) | Vorschaubild des Videos | Empfohlen: Mit Vorschaubild lädt vom Video vorab nichts |
|
||||||
|
| `intro.web.mp4` (optional) | Web-Fassung, wird bevorzugt angezeigt | Erzeugen mit `pnpm media:optimize`, siehe unten |
|
||||||
|
|
||||||
## Team (Ordner `team/`)
|
## Team (Ordner `team/`)
|
||||||
|
|
||||||
Hochformat 3:4 oder 4:5, mindestens 1200 px breit, Gesicht im oberen Drittel. Das Bild wird oben rund (als Bogen) beschnitten.
|
Hochformat 3:4 oder 4:5, mindestens 1200 px breit, Gesicht im oberen Drittel. Das Bild wird oben rund (als Bogen) beschnitten.
|
||||||
@@ -33,7 +41,6 @@ Hochformat 3:4 oder 4:5, mindestens 1200 px breit, Gesicht im oberen Drittel. Da
|
|||||||
| Lola Stoll | `team/lola-stoll.jpg` |
|
| Lola Stoll | `team/lola-stoll.jpg` |
|
||||||
| Eileen Kaupp | `team/eileen-kaupp.jpg` |
|
| Eileen Kaupp | `team/eileen-kaupp.jpg` |
|
||||||
| Gabriela Pronkin | `team/gabriela-pronkin.jpg` |
|
| Gabriela Pronkin | `team/gabriela-pronkin.jpg` |
|
||||||
| Stefan Höchtl | `team/stefan-hoechtl.jpg` |
|
|
||||||
| Saskia Sirowatka | `team/saskia-sirowatka.jpg` |
|
| Saskia Sirowatka | `team/saskia-sirowatka.jpg` |
|
||||||
| André Hartshauser | `team/andre-hartshauser.jpg` |
|
| André Hartshauser | `team/andre-hartshauser.jpg` |
|
||||||
| Ante Rejo | `team/ante-rejo.jpg` |
|
| Ante Rejo | `team/ante-rejo.jpg` |
|
||||||
@@ -50,6 +57,21 @@ Hochformat 9:16. Das Video startet erst nach Klick und nie von selbst mit Ton.
|
|||||||
|
|
||||||
Ein Reel erscheint öffentlich erst, wenn das Video vorhanden ist. Namen und Begleittext stehen in `src/content/site.ts` unter `reels.slots`. Sie dürfen nur mit Freigabe der gezeigten Person eingetragen werden.
|
Ein Reel erscheint öffentlich erst, wenn das Video vorhanden ist. Namen und Begleittext stehen in `src/content/site.ts` unter `reels.slots`. Sie dürfen nur mit Freigabe der gezeigten Person eingetragen werden.
|
||||||
|
|
||||||
|
## Vor dem Hochladen: Videos und große Bilder aufbereiten
|
||||||
|
|
||||||
|
Videos direkt aus Kamera oder Schnittprogramm sind oft riesig (ein Reel hatte 1,6 GB in 4K) und haben den Index am Dateiende. Der Browser muss dann sehr viel laden, bevor überhaupt etwas läuft. Deshalb einmal aufbereiten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm media:optimize C:\Pfad\zu\meinen-medien
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript legt daneben einen Ordner `meinen-medien-web` mit gleicher Struktur an:
|
||||||
|
|
||||||
|
- aus jedem Video eine `<name>.web.mp4` (höchstens 1920 px, H.264, sofort abspielbar; `header` ohne Ton) und, falls kein Vorschaubild vorhanden ist, eine `<name>.jpg`
|
||||||
|
- große Bilder verkleinert (Logo 1600 px, sonst 2560 px längste Seite), kleinere unverändert
|
||||||
|
|
||||||
|
Den Inhalt dieses Ordners ins Medienverzeichnis kopieren. Liegt `<name>.web.mp4` neben dem Original, zeigt die Seite automatisch die Web-Fassung; das Original kann dann auch entfernt werden. Bereits aufbereitete Dateien überspringt das Skript beim nächsten Lauf.
|
||||||
|
|
||||||
## Ablage im Cluster
|
## Ablage im Cluster
|
||||||
|
|
||||||
Die Dateien kommen auf die SMB-Freigabe hinter dem Volume (siehe `k8s/03-storage.yaml`), zum Beispiel:
|
Die Dateien kommen auf die SMB-Freigabe hinter dem Volume (siehe `k8s/03-storage.yaml`), zum Beispiel:
|
||||||
|
|||||||
+10
-7
@@ -1,6 +1,8 @@
|
|||||||
# Noch benötigte Inhalte und Freigaben
|
# Noch benötigte Inhalte und Freigaben
|
||||||
|
|
||||||
Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` eingetragen (im Admin änderbar):
|
Stand 16.10.2026. Dieselben Punkte stehen mit Verweis auf die Detail-Issues in der Gitea-Sammelliste [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32).
|
||||||
|
|
||||||
|
Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` bzw. `20261002090000_termin_mai_2027` eingetragen (im Admin änderbar). Damit sind im Admin die Freigaben Termin, Ort, Unterkunft und Platzzahl gesetzt, sofern sie dort niemand zurückgenommen hat:
|
||||||
|
|
||||||
- Termin 03.05. bis 09.05.2027 (laut Wochenplan vom 02.10.2026), Villa La Chiesina, Arcidosso (Toskana)
|
- Termin 03.05. bis 09.05.2027 (laut Wochenplan vom 02.10.2026), Villa La Chiesina, Arcidosso (Toskana)
|
||||||
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
|
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
|
||||||
@@ -9,14 +11,14 @@ Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027
|
|||||||
- Tagesablauf aller sieben Tage, Bustransfer ab und bis Florenz, Verpflegung (Frühstück, Snacks, Abendessen)
|
- Tagesablauf aller sieben Tage, Bustransfer ab und bis Florenz, Verpflegung (Frühstück, Snacks, Abendessen)
|
||||||
- Early Bird 01.11. bis 31.12.2026, ohne Anzeige eines Verkaufsstarts
|
- Early Bird 01.11. bis 31.12.2026, ohne Anzeige eines Verkaufsstarts
|
||||||
|
|
||||||
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“.
|
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“. Sind alle Freigaben erteilt und ist der Live-Verkauf eingeschaltet, startet der Verkauf sofort zum Early-Bird-Preis; die Early-Bird-Phase läuft vom 01.11. bis 31.12.2026.
|
||||||
|
|
||||||
## Inhalte
|
## Inhalte
|
||||||
|
|
||||||
| Punkt | Wo eintragen |
|
| Punkt | Wo eintragen |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Logo (`logo.svg`, `logo-hell.svg`) und Headerbild (`header.jpg`) | Medienverzeichnis, Dateinamen in `docs/MEDIEN.md` |
|
| Logo (`logo.svg`, `logo-hell.svg`) und Headerbild (`header.jpg`) | Medienverzeichnis, Dateinamen in `docs/MEDIEN.md` |
|
||||||
| Porträts der neun Teammitglieder | Medienverzeichnis `team/<name>.jpg`, siehe `docs/MEDIEN.md` |
|
| Porträts der acht Teammitglieder | Medienverzeichnis `team/<name>.jpg`, siehe `docs/MEDIEN.md` |
|
||||||
| Freigegebene Kurzbeschreibungen der Teammitglieder | `site.ts` → `team` |
|
| Freigegebene Kurzbeschreibungen der Teammitglieder | `site.ts` → `team` |
|
||||||
| Schriftliche Bestätigung des Vermieters, dass die Booking-Bilder genutzt werden dürfen (Urheber ist der Vermieter bzw. Fotograf) | Ablage beim Veranstalter |
|
| Schriftliche Bestätigung des Vermieters, dass die Booking-Bilder genutzt werden dürfen (Urheber ist der Vermieter bzw. Fotograf) | Ablage beim Veranstalter |
|
||||||
| Link zur ARTISTA-Stammmodel-Seite und daraus abgeleiteter Text „Über ARTISTA“ | `site.ts` → `about` |
|
| Link zur ARTISTA-Stammmodel-Seite und daraus abgeleiteter Text „Über ARTISTA“ | `site.ts` → `about` |
|
||||||
@@ -40,7 +42,8 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf
|
|||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt.
|
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt, Billing-Einstellungen für Folgeraten gesetzt (docs/BETRIEB.md, Abschnitt 4).
|
||||||
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain.
|
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain; Veranstalter-E-Mail oder `ADMIN_NOTIFY_EMAIL` gesetzt, sonst lehnt das Formular „Erstgespräch“ Anfragen ab.
|
||||||
- Domain, TLS und StorageClasses im Cluster (siehe docs/BETRIEB.md).
|
- Erledigt: Domain `modelcamp.artistasphotography.de`, TLS über cert-manager, SMB-Speicher und die StorageClass `smb-backup` im Cluster.
|
||||||
- Wiederherstellung aus der Sicherung einmal geübt.
|
- Wiederherstellung aus der Sicherung einmal geübt (noch offen).
|
||||||
|
- Optional: Google-Rezensionen (`GOOGLE_PLACES_API_KEY`, `GOOGLE_PLACE_ID` im Secret); ohne Werte bleibt der Abschnitt ausgeblendet.
|
||||||
+13
-6
@@ -1,10 +1,14 @@
|
|||||||
# Tests und Abnahme
|
# Tests und Abnahme
|
||||||
|
|
||||||
Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden.
|
Stand 16.10.2026. Die Abschnitte unten sind chronologisch gewachsen; der aktuelle Stand steht hier oben.
|
||||||
|
|
||||||
|
**Aktuell:** `pnpm test` umfasst 10 Testdateien (rund 130 Tests; 132 beim lokalen Lauf am 14.10.2026 zu Commit `f29dbcf`, seither kam mit `9785eb2` ein Regressionstest hinzu). Seit `0421d7d` (14.10.2026) laufen `lint` mit `pnpm audit`, `typecheck` und `test` bei jedem Push auf `main` in der CI; der Test-Job nutzt PostgreSQL 18.6 als Service (`TEST_DATABASE_URL`). Letzter CI-Lauf (`9785eb2`, 15.10.2026): lint, typecheck und test grün, nur der Image-Build schlug fehl (Issue #20). Die Playwright-Tests (`pnpm test:e2e`, 3 Tests je Profil) laufen nur lokal.
|
||||||
|
|
||||||
|
Seit dem ersten Stand hinzugekommene Testdateien: `client-ip.test.ts` (Client-IP hinter Traefik und Cloudflare, IPv6 je /64, Proxy um NextRequest), `contact.integration.test.ts` (Formular „Erstgespräch“), `email-outbox.integration.test.ts` (Postausgang, endgültige und vorübergehende SMTP-Fehler), `review-format.test.ts` (Google-Rezensionen).
|
||||||
|
|
||||||
## Automatisierte Tests
|
## Automatisierte Tests
|
||||||
|
|
||||||
`pnpm test` umfasst 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
|
Stand 27.09.2026: `pnpm test` umfasste 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
|
||||||
|
|
||||||
| Geschäftsrisiko | Datei |
|
| Geschäftsrisiko | Datei |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -33,7 +37,7 @@ Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden
|
|||||||
|
|
||||||
Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
||||||
|
|
||||||
Letzter Lauf 01.10.2026 nach Einbau von Team, Villa-Bildern und bestätigtem Termin: 81/81 Unit- und Integrationstests, 9/9 E2E-Tests, Produktionsbuild grün. Team und Unterkunft wurden zusätzlich per Bildschirmfoto auf Smartphone (390 px) und Desktop (1440 px) geprüft. Mit Termin 08.05.2027 fällt die letzte Rate auf den 08.04.2027.
|
Letzter Lauf 01.10.2026 nach Einbau von Team, Villa-Bildern und bestätigtem Termin: 81/81 Unit- und Integrationstests, 9/9 E2E-Tests, Produktionsbuild grün. Team und Unterkunft wurden zusätzlich per Bildschirmfoto auf Smartphone (390 px) und Desktop (1440 px) geprüft. Mit dem damaligen Termin 08.05.2027 fiel die letzte Rate auf den 08.04.2027; seit dem Termin 03.05.2027 (Migration vom 02.10.2026) ist es der 03.04.2027.
|
||||||
|
|
||||||
Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der Same-Origin-Schutz die Preisabfrage ab (403). Dann hängt der Buchungsdialog bei „Preise werden geladen …“. Server deshalb mit passender `APP_URL` starten, z. B. `APP_URL=http://localhost:3005 PORT=3005 pnpm start` und `E2E_BASE_URL=http://localhost:3005 pnpm test:e2e`.
|
Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der Same-Origin-Schutz die Preisabfrage ab (403). Dann hängt der Buchungsdialog bei „Preise werden geladen …“. Server deshalb mit passender `APP_URL` starten, z. B. `APP_URL=http://localhost:3005 PORT=3005 pnpm start` und `E2E_BASE_URL=http://localhost:3005 pnpm test:e2e`.
|
||||||
|
|
||||||
@@ -49,9 +53,12 @@ Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der
|
|||||||
- Der Standalone-Build läuft mit Datenbank. Worker-Lauf und `admin:create` funktionieren, eine doppelte Anlage wird abgewiesen.
|
- Der Standalone-Build läuft mit Datenbank. Worker-Lauf und `admin:create` funktionieren, eine doppelte Anlage wird abgewiesen.
|
||||||
- Build, Typprüfung und ESLint laufen ohne Fehler, Kustomize rendert 17 Ressourcen.
|
- Build, Typprüfung und ESLint laufen ohne Fehler, Kustomize rendert 17 Ressourcen.
|
||||||
|
|
||||||
|
Lauf 06.10.2026 nach Umstellung der Folgeraten auf Stripe Billing (inkl. Anhalten bei Prüfung und Schutz vor doppeltem Plan): 88/88 Unit- und Integrationstests, Typprüfung und ESLint ohne Fehler. Zusätzlich `StripeGateway` gegen die echte Stripe-Test-API mit Testuhr geprüft: Zeitplan mit zwei Folgeraten erzeugt genau zwei bezahlte Rechnungen (je 700 €, Metadaten `bookingId`/`kind`, PaymentIntent mit Charge) und endet danach (`completed`); abgelehnte Karte ergibt offene Rechnung mit geplantem Wiederholungsversuch und Zahlungslink; Storno setzt den Zeitplan auf `canceled` und die offene Rechnung auf `void`, auch wiederholt und vor dem Start.
|
||||||
|
|
||||||
## Nicht ausgeführt / Einschränkungen
|
## Nicht ausgeführt / Einschränkungen
|
||||||
|
|
||||||
- **Echte Stripe-Testbuchung** gegen die Stripe-API: Ein Stripe-Testschlüssel lag nicht vor. Die Stripe-Logik ist gegen eine Attrappe mit Stripe-Semantik getestet (Idempotenz, Session-Zustände, Off-Session-Ergebnisse), die Webhook-Signatur mit der echten Stripe-Bibliothek. Mit Schlüssel: Anleitung in der README (`stripe listen`, Testkarten).
|
- **Vollständige Stripe-Testbuchung** über die Oberfläche bis zum Webhook: In der Testsuite läuft die Stripe-Logik gegen eine Attrappe mit Stripe-Semantik (Idempotenz, Session-Zustände, Rechnungen des Ratenplans), die Webhook-Signatur mit der echten Stripe-Bibliothek. Gegen die echte Stripe-Test-API geprüft ist bisher nur der Ratenplan (`StripeGateway` mit Testuhr, Lauf vom 06.10.2026). Ein dokumentierter Durchlauf Buchung → Checkout → Webhook → Bestätigungsmail auf dem Cluster fehlt (Issue #27). Anleitung lokal in der README (`stripe listen`, Testkarten).
|
||||||
- **Docker-Image und Cluster:** Auf dem Entwicklungsrechner gibt es kein Docker und keinen Cluster-Zugang. Das Dockerfile ist nicht gebaut und nicht im Cluster ausgerollt. Geprüft sind der Standalone-Build und das Rendern der Manifeste.
|
- **Docker-Image und Cluster:** Seit dem 27.09.2026 baut die CI beide Images; die Seite läuft im Cluster (zuletzt belegt ausgerollt: `sha-5a88f3d`, 13.10.2026). Eine automatisierte Prüfung nach dem Rollout gibt es nicht; der Monitoring-Dienst Gatus im Cluster prüft die Erreichbarkeit.
|
||||||
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft.
|
- **SMTP-Versand:** im Repo nur über den Datei-Testausgang geprüft. Ob im Cluster ein echter SMTP-Zugang eingetragen ist, ist hier nicht dokumentiert.
|
||||||
|
- **E2E-Tests** laufen nicht in der CI.
|
||||||
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
||||||
@@ -23,7 +23,7 @@ spec:
|
|||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
containers:
|
containers:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
image: postgres:18.1-alpine
|
image: postgres:18.6-alpine
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5432
|
- containerPort: 5432
|
||||||
env:
|
env:
|
||||||
@@ -41,7 +41,7 @@ spec:
|
|||||||
exec: { command: ["pg_isready", "-U", "artista"] }
|
exec: { command: ["pg_isready", "-U", "artista"] }
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 100m, memory: 256Mi }
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 1Gi }
|
limits: { memory: 1Gi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -24,20 +24,22 @@ spec:
|
|||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
|
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||||
imagePullPolicy: Always
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
command: ["prisma", "migrate", "deploy"]
|
command: ["prisma", "migrate", "deploy"]
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef: { name: artista-modelcamp-secrets }
|
- secretRef: { name: artista-modelcamp-secrets }
|
||||||
resources:
|
resources:
|
||||||
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 512Mi }
|
limits: { memory: 512Mi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
capabilities: { drop: ["ALL"] }
|
capabilities: { drop: ["ALL"] }
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp:latest
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp:sha-REPLACE_ME
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3000
|
containerPort: 3000
|
||||||
@@ -52,7 +54,7 @@ spec:
|
|||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 200m, memory: 256Mi }
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 768Mi }
|
limits: { memory: 768Mi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Alle 10 Minuten: faellige Raten einziehen, abgelaufene Reservierungen
|
# Alle 10 Minuten: abgelaufene Reservierungen freigeben, fehlende
|
||||||
# freigeben, E-Mails nachsenden. Ohne ihn funktioniert Ratenzahlung nicht.
|
# Stripe-Ratenplaene anlegen, ueberfaellige Raten mit Stripe abgleichen,
|
||||||
|
# E-Mails nachsenden (Einzug der Folgeraten macht Stripe Billing,
|
||||||
|
# docs/ARCHITEKTUR.md ADR-3). Ohne ihn funktioniert Ratenzahlung nicht.
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: CronJob
|
kind: CronJob
|
||||||
metadata:
|
metadata:
|
||||||
@@ -10,8 +12,13 @@ spec:
|
|||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 1
|
# Kein Sofort-Neustart: der naechste Lauf kommt nach 10 Minuten ohnehin,
|
||||||
|
# ein zweiter Pod je Fehlschlag verdoppelt nur Fehler-Pods und Versuche.
|
||||||
|
backoffLimit: 0
|
||||||
activeDeadlineSeconds: 900
|
activeDeadlineSeconds: 900
|
||||||
|
# Beendete Jobs samt Pods nach einem Tag entfernen; fehlgeschlagene
|
||||||
|
# blieben sonst bis zum naechsten Fehlschlag stehen (failedJobsHistoryLimit 3).
|
||||||
|
ttlSecondsAfterFinished: 86400
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -25,13 +32,15 @@ spec:
|
|||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
containers:
|
containers:
|
||||||
- name: worker
|
- name: worker
|
||||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
|
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||||
imagePullPolicy: Always
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
command: ["tsx", "scripts/worker.ts"]
|
command: ["tsx", "scripts/worker.ts"]
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef: { name: artista-modelcamp-config }
|
- configMapRef: { name: artista-modelcamp-config }
|
||||||
- secretRef: { name: artista-modelcamp-secrets }
|
- secretRef: { name: artista-modelcamp-secrets }
|
||||||
resources:
|
resources:
|
||||||
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 512Mi }
|
limits: { memory: 512Mi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Taegliche pg_dump-Sicherung der Modelcamp-Datenbank auf die Storage Box.
|
||||||
|
# Voraussetzung im Cluster: StorageClass smb-backup (SMB 3.1.1 mit seal),
|
||||||
|
# legt pro PVC einen Ordner unter Server/Backups/<namespace>/ an.
|
||||||
|
# Aufbewahrung: 14 Tage taeglich, 8 Wochen sonntags.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: artista-db-backup
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteMany]
|
||||||
|
storageClassName: smb-backup
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: artista-db-backup
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
schedule: "25 2 * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
successfulJobsHistoryLimit: 3
|
||||||
|
failedJobsHistoryLimit: 3
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
backoffLimit: 1
|
||||||
|
activeDeadlineSeconds: 1800
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: artista-db-backup
|
||||||
|
db-client: "true"
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 70
|
||||||
|
runAsGroup: 70
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: backup
|
||||||
|
image: postgres:18.6-alpine
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
stamp=$(date +%Y-%m-%d)
|
||||||
|
kind=daily; [ "$(date +%u)" = "7" ] && kind=weekly
|
||||||
|
mkdir -p /backups/daily /backups/weekly
|
||||||
|
pg_dump -h artista-modelcamp-postgres -Fc -f /backups/$kind/artista-$stamp.dump.tmp
|
||||||
|
mv /backups/$kind/artista-$stamp.dump.tmp /backups/$kind/artista-$stamp.dump
|
||||||
|
find /backups/daily -name '*.dump' -mtime +14 -delete
|
||||||
|
find /backups/weekly -name '*.dump' -mtime +56 -delete
|
||||||
|
ls -l /backups/$kind | tail -3
|
||||||
|
env:
|
||||||
|
- {name: PGUSER, value: artista}
|
||||||
|
- {name: PGDATABASE, value: artista}
|
||||||
|
- name: PGPASSWORD
|
||||||
|
valueFrom: {secretKeyRef: {name: artista-modelcamp-secrets, key: DB_PASSWORD}}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: "0", memory: "0"}
|
||||||
|
limits: {memory: 512Mi}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: {drop: [ALL]}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: backups, mountPath: /backups}
|
||||||
|
volumes:
|
||||||
|
- name: backups
|
||||||
|
persistentVolumeClaim: {claimName: artista-db-backup}
|
||||||
+19
-3
@@ -4,6 +4,7 @@ Rohe Manifeste wie bei Wabenhain, Nummerierung = Reihenfolge. Voraussetzungen im
|
|||||||
- **Traefik** als Ingress-Controller (IngressClass `traefik`)
|
- **Traefik** als Ingress-Controller (IngressClass `traefik`)
|
||||||
- **cert-manager** mit dem ClusterIssuer `letsencrypt-prod`
|
- **cert-manager** mit dem ClusterIssuer `letsencrypt-prod`
|
||||||
- **csi-driver-smb** für die SMB-Volumes
|
- **csi-driver-smb** für die SMB-Volumes
|
||||||
|
- die StorageClass **`smb-backup`** für den Sicherungs-CronJob (`09-db-backup-cronjob.yaml`); im EnCry-Cluster seit 12.10.2026 vorhanden
|
||||||
|
|
||||||
Prüfen:
|
Prüfen:
|
||||||
|
|
||||||
@@ -11,8 +12,11 @@ Prüfen:
|
|||||||
kubectl get ingressclass
|
kubectl get ingressclass
|
||||||
kubectl get clusterissuer letsencrypt-prod
|
kubectl get clusterissuer letsencrypt-prod
|
||||||
kubectl get csidrivers | grep smb
|
kubectl get csidrivers | grep smb
|
||||||
|
kubectl get storageclass smb-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Im EnCry-Cluster läuft die Seite unter `modelcamp.artistasphotography.de`. Der DNS-Eintrag ist bei Cloudflare bewusst nicht geproxyt (DNS-only), deshalb bekommt dieser Ingress keine `cloudflare-only`-Middleware. Im selben Namespace läuft Artista-Telefonie (Repo `Artista-TeleTracking`) mit einer eigenen Datenbank `telefonie` in `artista-modelcamp-postgres`.
|
||||||
|
|
||||||
## Einmalige Vorbereitung
|
## Einmalige Vorbereitung
|
||||||
|
|
||||||
1. **Images:** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` die Images `git.encry.de/nick.rosenloecher/artista-modelcamp:latest` und `…-tools:latest`. Voraussetzung ist das Repo-Secret `REGISTRY_TOKEN` (Personal Access Token mit `package`-Scope), wie bei Wabenhain.
|
1. **Images:** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` die Images `git.encry.de/nick.rosenloecher/artista-modelcamp:latest` und `…-tools:latest`. Voraussetzung ist das Repo-Secret `REGISTRY_TOKEN` (Personal Access Token mit `package`-Scope), wie bei Wabenhain.
|
||||||
@@ -22,8 +26,9 @@ kubectl get csidrivers | grep smb
|
|||||||
helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
|
helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
|
||||||
```
|
```
|
||||||
3. **NAS:** Die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. `nas.example.lan` in `03-storage.yaml` anpassen.
|
3. **NAS:** Die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. `nas.example.lan` in `03-storage.yaml` anpassen.
|
||||||
4. **Domain:** `modelcamp.example.org` in `01-configmap.yaml` (APP_URL) und `06-ingress.yaml` (2×) ersetzen. Der DNS-Eintrag muss auf den Cluster zeigen, sonst stellt cert-manager kein Zertifikat aus.
|
4. **Domain:** `modelcamp.example.org` in `01-configmap.yaml` (APP_URL) und `06-ingress.yaml` (2×) ersetzen (im EnCry-Cluster `modelcamp.artistasphotography.de`). Der DNS-Eintrag muss auf den Cluster zeigen, sonst stellt cert-manager kein Zertifikat aus.
|
||||||
5. **Secrets:** `cp k8s/02-secret.yaml.example k8s/02-secret.yaml` und ausfüllen. Die Datei steht in `.gitignore`, bitte **nie** mit echten Werten committen.
|
5. **Secrets:** `cp k8s/02-secret.yaml.example k8s/02-secret.yaml` und ausfüllen. Die Datei steht in `.gitignore`, bitte **nie** mit echten Werten committen.
|
||||||
|
6. **Image-Tag:** In `05-app-deployment.yaml` (2×) und `07-worker-cronjob.yaml` steht der Platzhalter `sha-REPLACE_ME`. Vor dem ersten Anwenden durch den Tag des letzten grünen CI-Laufs ersetzen (`sha-<commit>`, 7 Zeichen) oder nach dem Anwenden wie unter „Bei einem neuen Image“ setzen.
|
||||||
|
|
||||||
## Deployment (auch für einen kompletten Neuaufbau)
|
## Deployment (auch für einen kompletten Neuaufbau)
|
||||||
|
|
||||||
@@ -38,6 +43,7 @@ kubectl apply -f k8s/05-app-deployment.yaml
|
|||||||
kubectl apply -f k8s/06-ingress.yaml
|
kubectl apply -f k8s/06-ingress.yaml
|
||||||
kubectl apply -f k8s/07-worker-cronjob.yaml
|
kubectl apply -f k8s/07-worker-cronjob.yaml
|
||||||
kubectl apply -f k8s/08-networkpolicy.yaml
|
kubectl apply -f k8s/08-networkpolicy.yaml
|
||||||
|
kubectl apply -f k8s/09-db-backup-cronjob.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Migration läuft automatisch als initContainer vor dem App-Start. Den Stand zeigen:
|
Die Migration läuft automatisch als initContainer vor dem App-Start. Den Stand zeigen:
|
||||||
@@ -47,7 +53,7 @@ kubectl rollout status deployment/artista-modelcamp-app -n artista
|
|||||||
kubectl get certificate -n artista # READY muss True werden (meist < 2 Minuten)
|
kubectl get certificate -n artista # READY muss True werden (meist < 2 Minuten)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Neuaufbau mit `kubectl delete namespace artista`:** Die PersistentVolumes (`artista-modelcamp-postgres`, `artista-modelcamp-media`) gehören keinem Namespace und bleiben auf `Released` stehen. Die Daten auf dem NAS bleiben erhalten (`Retain`). Damit die neuen Claims sie wieder binden können, vor dem erneuten Anlegen:
|
**Neuaufbau mit `kubectl delete namespace artista`:** Achtung, das löscht auch Artista-Telefonie samt deren Secret; deren Datenbank liegt auf demselben Postgres-Volume. Die PersistentVolumes (`artista-modelcamp-postgres`, `artista-modelcamp-media`) gehören keinem Namespace und bleiben auf `Released` stehen. Die Daten auf dem NAS bleiben erhalten (`Retain`). Damit die neuen Claims sie wieder binden können, vor dem erneuten Anlegen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl patch pv artista-modelcamp-postgres -p '{"spec":{"claimRef":null}}'
|
kubectl patch pv artista-modelcamp-postgres -p '{"spec":{"claimRef":null}}'
|
||||||
@@ -68,10 +74,20 @@ kubectl -n artista run admin-create --rm -i --restart=Never \
|
|||||||
|
|
||||||
## Bei einem neuen Image (Redeploy)
|
## Bei einem neuen Image (Redeploy)
|
||||||
|
|
||||||
|
Die Manifeste sind auf einen Build gepinnt, nicht auf `:latest`. Den Tag `sha-<commit>` (7 Zeichen) zeigt der CI-Lauf in der Zusammenfassung. App und Werkzeuge immer mit demselben Tag ausrollen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl rollout restart deployment/artista-modelcamp-app -n artista
|
TAG=sha-<commit>
|
||||||
|
kubectl -n artista set image deployment/artista-modelcamp-app \
|
||||||
|
migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG \
|
||||||
|
app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
|
||||||
|
kubectl -n artista set image cronjob/artista-modelcamp-worker \
|
||||||
|
worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
|
||||||
|
kubectl -n artista rollout status deployment/artista-modelcamp-app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Zurueck auf den vorherigen Stand: `kubectl -n artista rollout undo deployment/artista-modelcamp-app` (den CronJob dann wieder auf den alten Tag setzen).
|
||||||
|
|
||||||
## Fehlersuche
|
## Fehlersuche
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const nextConfig: NextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
qualities: [75, 85],
|
qualities: [75, 85],
|
||||||
|
// Optimierte Bilder 30 Tage im Browser cachen: Medien-URLs tragen ?v=<Aenderungsdatum>,
|
||||||
|
// ein ersetztes Bild hat also ohnehin eine neue Adresse.
|
||||||
|
minimumCacheTTL: 2_592_000,
|
||||||
// Feste Bilder (Unterkunft, Team) liegen in public/images/, austauschbare
|
// Feste Bilder (Unterkunft, Team) liegen in public/images/, austauschbare
|
||||||
// im persistenten Medienverzeichnis unter /media/… (src/app/media/[...path]/route.ts).
|
// im persistenten Medienverzeichnis unter /media/… (src/app/media/[...path]/route.ts).
|
||||||
localPatterns: [{ pathname: "/media/**" }, { pathname: "/images/**" }],
|
localPatterns: [{ pathname: "/media/**" }, { pathname: "/images/**" }],
|
||||||
|
|||||||
+5
-2
@@ -16,6 +16,7 @@
|
|||||||
"db:seed": "tsx scripts/seed-dev.ts",
|
"db:seed": "tsx scripts/seed-dev.ts",
|
||||||
"admin:create": "tsx scripts/create-admin.ts",
|
"admin:create": "tsx scripts/create-admin.ts",
|
||||||
"worker": "tsx scripts/worker.ts",
|
"worker": "tsx scripts/worker.ts",
|
||||||
|
"media:optimize": "tsx scripts/optimize-media.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
@@ -26,13 +27,14 @@
|
|||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"better-auth": "1.7.6",
|
"better-auth": "1.7.6",
|
||||||
"motion": "13.4.4",
|
"motion": "13.4.4",
|
||||||
"next": "16.3.6",
|
"next": "16.3.8",
|
||||||
"nodemailer": "10.0.10",
|
"nodemailer": "10.0.10",
|
||||||
"pg": "8.23.0",
|
"pg": "8.23.0",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
|
"sharp": "0.35.5",
|
||||||
"stripe": "22.6.2",
|
"stripe": "22.6.2",
|
||||||
"zod": "4.6.5"
|
"zod": "4.6.5"
|
||||||
},
|
},
|
||||||
@@ -49,7 +51,8 @@
|
|||||||
"dotenv": "18.0.4",
|
"dotenv": "18.0.4",
|
||||||
"embedded-postgres": "18.4.0-beta.17",
|
"embedded-postgres": "18.4.0-beta.17",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "16.3.6",
|
"eslint-config-next": "16.3.8",
|
||||||
|
"ffmpeg-static": "5.3.0",
|
||||||
"prisma": "7.10.0",
|
"prisma": "7.10.0",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"tsx": "4.23.15",
|
"tsx": "4.23.15",
|
||||||
|
|||||||
Generated
+354
-221
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,21 @@ allowBuilds:
|
|||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
sharp: true
|
sharp: true
|
||||||
|
ffmpeg-static: true
|
||||||
embedded-postgres: true
|
embedded-postgres: true
|
||||||
'@embedded-postgres/windows-x64': true
|
'@embedded-postgres/windows-x64': true
|
||||||
'@embedded-postgres/linux-x64': true
|
'@embedded-postgres/linux-x64': true
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
|
|
||||||
|
# Transitive Luecken ab "high" (pnpm audit --prod, CI-Gate in
|
||||||
|
# .gitea/workflows/docker-build.yml). Gleiche Untergrenzen wie im Wabenhain-Repo.
|
||||||
|
overrides:
|
||||||
|
# mysql2 und deepmerge-ts kommen ueber die prisma-CLI (@prisma/config) herein,
|
||||||
|
# nicht ueber den Laufzeitpfad (die App nutzt @prisma/adapter-pg).
|
||||||
|
# deepmerge-ts ist ein Hauptversionssprung; "prisma generate" laeuft durch
|
||||||
|
# @prisma/config und prueft ihn bei jedem typecheck/build mit.
|
||||||
|
mysql2: '>=3.23.1'
|
||||||
|
deepmerge-ts: '>=8.0.0'
|
||||||
|
# Transitiv ueber next (next pinnt sharp sonst auf 0.35.4); reine Patch-Spruenge.
|
||||||
|
sharp: '>=0.35.5'
|
||||||
|
source-map-js: '>=1.2.2'
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Folgeraten ueber Stripe Billing (Subscription Schedule) statt eigenem Off-Session-Einzug.
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "stripeScheduleId" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "Booking_stripeScheduleId_key" ON "Booking"("stripeScheduleId");
|
||||||
|
|
||||||
|
ALTER TABLE "Installment" ADD COLUMN "stripeInvoiceId" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "Installment_stripeInvoiceId_key" ON "Installment"("stripeInvoiceId");
|
||||||
@@ -191,6 +191,9 @@ model Booking {
|
|||||||
/// Zahlungsmethode. Wird nach der letzten Rate bei Stripe getrennt.
|
/// Zahlungsmethode. Wird nach der letzten Rate bei Stripe getrennt.
|
||||||
stripePaymentMethodId String?
|
stripePaymentMethodId String?
|
||||||
paymentMethodDetachedAt DateTime?
|
paymentMethodDetachedAt DateTime?
|
||||||
|
/// Stripe-Billing-Zeitplan, der die Folgeraten einzieht (nur Ratenzahlung).
|
||||||
|
/// Stripe uebernimmt Einzug, Wiederholungen und Mahnungen.
|
||||||
|
stripeScheduleId String? @unique
|
||||||
|
|
||||||
paidCents Int @default(0)
|
paidCents Int @default(0)
|
||||||
refundedCents Int @default(0)
|
refundedCents Int @default(0)
|
||||||
@@ -246,6 +249,8 @@ model Installment {
|
|||||||
|
|
||||||
stripePaymentIntentId String? @unique
|
stripePaymentIntentId String? @unique
|
||||||
stripeChargeId String? @unique
|
stripeChargeId String? @unique
|
||||||
|
/// Stripe-Rechnung, ueber die Stripe Billing diese Folgerate einzieht.
|
||||||
|
stripeInvoiceId String? @unique
|
||||||
/// Offene Checkout-Session, ueber die die Kundin diese Rate selbst zahlt.
|
/// Offene Checkout-Session, ueber die die Kundin diese Rate selbst zahlt.
|
||||||
checkoutSessionId String? @unique
|
checkoutSessionId String? @unique
|
||||||
checkoutExpiresAt DateTime?
|
checkoutExpiresAt DateTime?
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prüft im CI-Job die Grenzen des isolierten Runners (Label ci-isolated,
|
||||||
|
# Runner-Doku im VyCept-Repo, docs/BETRIEB.md 11b; Kopie von dort). Läuft als erster Schritt jedes Prüf-Jobs, damit jeder
|
||||||
|
# Runner bei jedem Lauf geprüft wird. Schlägt fehl, wenn der Job mehr darf als
|
||||||
|
# vorgesehen – oder wenn die Prüfung selbst nichts aussagen würde.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Runner-Isolation verletzt: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
reachable() {
|
||||||
|
timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Namen mit Wiederholung auflösen: Das DNS des Job-Netzes antwortet unter Last
|
||||||
|
# gelegentlich erst beim zweiten Versuch.
|
||||||
|
resolve() {
|
||||||
|
local address=""
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
address=$(getent hosts "$1" | awk '{ print $1; exit }' || true)
|
||||||
|
if [ -n "$address" ]; then echo "$address"; return 0; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Kein Zugang zum Docker-Daemon über den Socket.
|
||||||
|
[ -z "${DOCKER_HOST:-}" ] || fail "DOCKER_HOST ist gesetzt ($DOCKER_HOST)"
|
||||||
|
for socket in /var/run/docker.sock /run/docker.sock; do
|
||||||
|
[ ! -e "$socket" ] || fail "Docker-Socket $socket ist sichtbar"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Kein Zugang zur Docker-API des Daemons im Runner-Pod (Gateway des Job-Netzes):
|
||||||
|
# 2375 (ohne TLS) zu, 2376 nur mit Client-Zertifikat, das der Job nicht hat.
|
||||||
|
gateway_hex=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)
|
||||||
|
[ -n "$gateway_hex" ] || fail "kein Standard-Gateway gefunden – Prüfung wäre wirkungslos"
|
||||||
|
gateway=$(printf '%d.%d.%d.%d' "0x${gateway_hex:6:2}" "0x${gateway_hex:4:2}" "0x${gateway_hex:2:2}" "0x${gateway_hex:0:2}")
|
||||||
|
if reachable "$gateway" 2375; then fail "Docker-API ohne TLS am Gateway $gateway:2375"; fi
|
||||||
|
if curl -sk --max-time 5 "https://$gateway:2376/version" >/dev/null 2>&1; then
|
||||||
|
fail "Docker-API am Gateway $gateway:2376 ohne Client-Zertifikat nutzbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Kein Clusternetz: Kubernetes-API (Service und Knoten) und fremde Namespaces.
|
||||||
|
for target in 10.43.0.1:443 10.50.1.51:6443 10.50.1.52:6443 10.50.1.53:6443; do
|
||||||
|
if reachable "${target%:*}" "${target##*:}"; then fail "$target ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
resolve kubernetes.default.svc.cluster.local >/dev/null || fail "Clusternamen nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
# Fremder Namespace (VyCept-Datenbank) und eigener Namespace (Gitea-Datenbank).
|
||||||
|
for target in vycept-postgres.vycept.svc.cluster.local:5432 gitea-postgres.gitea.svc.cluster.local:5432; do
|
||||||
|
host=${target%:*}
|
||||||
|
address=$(resolve "$host") || fail "$host nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
if reachable "$address" "${target##*:}"; then fail "$host ($address) ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. Internet bleibt erreichbar (npm, GitHub, Images).
|
||||||
|
reachable registry.npmjs.org 443 || fail "Internet (registry.npmjs.org) nicht erreichbar"
|
||||||
|
|
||||||
|
echo "Runner-Isolation in Ordnung (Gateway $gateway, kein Socket, kein Clusternetz)"
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Bereitet Bilder und Videos fuer das Medienverzeichnis auf, bevor sie
|
||||||
|
* hochgeladen werden (docs/MEDIEN.md):
|
||||||
|
*
|
||||||
|
* pnpm media:optimize <eingabe-ordner> [ausgabe-ordner]
|
||||||
|
*
|
||||||
|
* - Videos (.mp4, .mov, .m4v, .webm) → "<name>.web.mp4": laengste Seite
|
||||||
|
* hoechstens 1920 px, H.264, Index am Dateianfang (faststart), damit der
|
||||||
|
* Browser sofort abspielen kann statt erst die ganze Datei zu laden.
|
||||||
|
* Headervideos ohne Ton. Fehlt ein Vorschaubild, wird eins erzeugt.
|
||||||
|
* Die Seite nimmt automatisch die .web.mp4, wenn sie daneben liegt.
|
||||||
|
* - Bilder groesser als noetig werden verkleinert (Logo 1600 px, sonst
|
||||||
|
* 2560 px laengste Seite), kleinere unveraendert kopiert.
|
||||||
|
*
|
||||||
|
* Ausgabe standardmaessig in "<eingabe-ordner>-web" mit gleicher
|
||||||
|
* Ordnerstruktur; diesen Inhalt ins Medienverzeichnis kopieren. Bereits
|
||||||
|
* aufbereitete Dateien (neuer als das Original) werden uebersprungen.
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { copyFile, mkdir, readdir, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import ffmpegPath from "ffmpeg-static";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const VIDEO_EXTS = new Set([".mp4", ".mov", ".m4v", ".webm"]);
|
||||||
|
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
|
||||||
|
const MAX_VIDEO_EDGE = 1920;
|
||||||
|
const MAX_IMAGE_EDGE = 2560;
|
||||||
|
const MAX_LOGO_EDGE = 1600;
|
||||||
|
const POSTER_SECOND = 1;
|
||||||
|
|
||||||
|
async function* walk(dir: string): AsyncGenerator<string> {
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) yield* walk(full);
|
||||||
|
else if (entry.isFile()) yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFresh(output: string, input: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(output)).mtimeMs >= (await stat(input)).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(file: string): Promise<boolean> {
|
||||||
|
return stat(file).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffmpeg(args: string[]): Promise<void> {
|
||||||
|
const binary = ffmpegPath as unknown as string | null;
|
||||||
|
if (!binary) throw new Error("ffmpeg-static liefert fuer dieses System kein ffmpeg.");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(binary, ["-hide_banner", "-loglevel", "error", "-stats", "-y", ...args], { stdio: ["ignore", "inherit", "inherit"] });
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg endete mit Code ${code}`))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laengste Seite auf MAX_VIDEO_EDGE begrenzen, nie vergroessern, gerade Masse fuer H.264. */
|
||||||
|
const SCALE_FILTER = `scale='if(gte(iw,ih),min(${MAX_VIDEO_EDGE},iw),-2)':'if(gte(iw,ih),-2,min(${MAX_VIDEO_EDGE},ih))'`;
|
||||||
|
|
||||||
|
async function hasPoster(input: string, base: string): Promise<boolean> {
|
||||||
|
const dir = path.dirname(input);
|
||||||
|
const name = path.basename(base);
|
||||||
|
for (const ext of IMAGE_EXTS) {
|
||||||
|
if (await exists(path.join(dir, `${name}${ext}`))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeVideo(input: string, outDir: string, base: string): Promise<string[]> {
|
||||||
|
const output = path.join(outDir, `${base}.web.mp4`);
|
||||||
|
const written: string[] = [];
|
||||||
|
if (!(await isFresh(output, input))) {
|
||||||
|
const silent = base.toLowerCase() === "header";
|
||||||
|
await ffmpeg([
|
||||||
|
"-i", input,
|
||||||
|
"-vf", SCALE_FILTER,
|
||||||
|
"-c:v", "libx264", "-preset", "slow", "-crf", "23", "-profile:v", "high", "-pix_fmt", "yuv420p",
|
||||||
|
"-maxrate", "6M", "-bufsize", "12M",
|
||||||
|
...(silent ? ["-an"] : ["-c:a", "aac", "-b:a", "128k"]),
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
output,
|
||||||
|
]);
|
||||||
|
written.push(output);
|
||||||
|
}
|
||||||
|
const poster = path.join(outDir, `${base}.jpg`);
|
||||||
|
if (!(await hasPoster(input, base)) && !(await isFresh(poster, input))) {
|
||||||
|
await ffmpeg(["-ss", String(POSTER_SECOND), "-i", input, "-frames:v", "1", "-vf", SCALE_FILTER, "-q:v", "3", poster]);
|
||||||
|
written.push(poster);
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeImage(input: string, output: string): Promise<string[]> {
|
||||||
|
if (await isFresh(output, input)) return [];
|
||||||
|
const isLogo = path.basename(input).toLowerCase().startsWith("logo");
|
||||||
|
const limit = isLogo ? MAX_LOGO_EDGE : MAX_IMAGE_EDGE;
|
||||||
|
const meta = await sharp(input).metadata();
|
||||||
|
if (Math.max(meta.width ?? 0, meta.height ?? 0) <= limit) {
|
||||||
|
await copyFile(input, output);
|
||||||
|
return [output];
|
||||||
|
}
|
||||||
|
const resized = sharp(input).rotate().resize(limit, limit, { fit: "inside", withoutEnlargement: true });
|
||||||
|
const ext = path.extname(input).toLowerCase();
|
||||||
|
if (ext === ".png") await resized.png({ compressionLevel: 9 }).toFile(output);
|
||||||
|
else if (ext === ".webp") await resized.webp({ quality: 82 }).toFile(output);
|
||||||
|
else await resized.jpeg({ quality: 82, mozjpeg: true }).toFile(output);
|
||||||
|
return [output];
|
||||||
|
}
|
||||||
|
|
||||||
|
function megabytes(bytes: number): string {
|
||||||
|
return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const [inputArg, outputArg] = process.argv.slice(2);
|
||||||
|
if (!inputArg) {
|
||||||
|
console.error("Aufruf: pnpm media:optimize <eingabe-ordner> [ausgabe-ordner]");
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inputDir = path.resolve(inputArg);
|
||||||
|
const outputDir = path.resolve(outputArg ?? `${inputDir}-web`);
|
||||||
|
if (outputDir === inputDir) throw new Error("Ausgabe-Ordner muss sich vom Eingabe-Ordner unterscheiden.");
|
||||||
|
|
||||||
|
let before = 0;
|
||||||
|
let after = 0;
|
||||||
|
for await (const input of walk(inputDir)) {
|
||||||
|
const rel = path.relative(inputDir, input);
|
||||||
|
const ext = path.extname(rel).toLowerCase();
|
||||||
|
if (!VIDEO_EXTS.has(ext) && !IMAGE_EXTS.has(ext)) continue;
|
||||||
|
if (rel.toLowerCase().endsWith(".web.mp4")) continue;
|
||||||
|
const outDir = path.join(outputDir, path.dirname(rel));
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
console.log(`→ ${rel}`);
|
||||||
|
const written = VIDEO_EXTS.has(ext)
|
||||||
|
? await optimizeVideo(input, outDir, path.basename(rel, path.extname(rel)))
|
||||||
|
: await optimizeImage(input, path.join(outDir, path.basename(rel)));
|
||||||
|
if (written.length === 0) {
|
||||||
|
console.log(" bereits aktuell");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const inSize = (await stat(input)).size;
|
||||||
|
const outSize = (await Promise.all(written.map((f) => stat(f)))).reduce((sum, s) => sum + s.size, 0);
|
||||||
|
before += inSize;
|
||||||
|
after += outSize;
|
||||||
|
console.log(` ${megabytes(inSize)} → ${megabytes(outSize)}: ${written.map((f) => path.relative(outputDir, f)).join(", ")}`);
|
||||||
|
}
|
||||||
|
console.log(`Fertig: ${megabytes(before)} → ${megabytes(after)}. Inhalt von ${outputDir} ins Medienverzeichnis kopieren.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(`Abbruch: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+5
-1
@@ -25,7 +25,11 @@ try {
|
|||||||
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
|
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
|
||||||
});
|
});
|
||||||
console.log(`[worker] ${new Date().toISOString()} ${JSON.stringify(result)}`);
|
console.log(`[worker] ${new Date().toISOString()} ${JSON.stringify(result)}`);
|
||||||
process.exitCode = result.emails.failed > 0 ? 1 : 0;
|
// Teilfehler (einzelne Buchungen bei Stripe) machen den Job sichtbar
|
||||||
|
// fehlgeschlagen, ohne die uebrigen Aufgaben des Laufs zu verhindern.
|
||||||
|
const partialFailures =
|
||||||
|
result.emails.failed + result.held.failed + result.schedules.failed + result.installments.failed + result.stopped.failed;
|
||||||
|
process.exitCode = partialFailures > 0 ? 1 : 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
|
console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro
|
|||||||
name="installmentRuleApproved"
|
name="installmentRuleApproved"
|
||||||
label="Terminregel für Raten freigegeben"
|
label="Terminregel für Raten freigegeben"
|
||||||
defaultChecked={camp.installmentRuleApproved}
|
defaultChecked={camp.installmentRuleApproved}
|
||||||
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Einzug 9 Uhr."
|
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Stripe zieht ab 9 Uhr ein."
|
||||||
/>
|
/>
|
||||||
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
|
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
|
||||||
<label className="mt-3 block">
|
<label className="mt-3 block">
|
||||||
|
|||||||
@@ -3,11 +3,33 @@ import { notFound } from "next/navigation";
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||||
|
import { getCamp, salesStateFor } from "@/lib/camp";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
|
import { stripeModeOrNull } from "@/lib/env";
|
||||||
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
||||||
|
import { evaluateVoucher, VOUCHER_REJECTION_ADMIN_TEXT } from "@/lib/vouchers";
|
||||||
|
|
||||||
export const metadata = { title: "Gutschein" };
|
export const metadata = { title: "Gutschein" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft wie der Buchungsdialog, ob der Code gerade einloesbar ist, und nennt
|
||||||
|
* den Grund, den Kundinnen bewusst nicht sehen (neutrale Ablehnung).
|
||||||
|
* Eine E-Mail-Bindung prueft erst der Checkout.
|
||||||
|
*/
|
||||||
|
async function redeemability(code: string): Promise<{ ok: boolean; text: string }> {
|
||||||
|
const mode = stripeModeOrNull();
|
||||||
|
if (!mode) return { ok: false, text: "Nicht prüfbar: STRIPE_SECRET_KEY fehlt oder ist ungültig." };
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const camp = await getCamp(db);
|
||||||
|
const evaluation = await evaluateVoucher(db, { code, email: null, now, livemode: mode === "live", quota: camp.stammmodelQuota, lock: false });
|
||||||
|
if (!evaluation.ok) return { ok: false, text: `Derzeit nicht einlösbar: ${VOUCHER_REJECTION_ADMIN_TEXT[evaluation.reason]}.` };
|
||||||
|
if (!salesStateFor(camp, mode, now).checkoutOpen) {
|
||||||
|
return { ok: false, text: "Code ist gültig, aber die Buchung ist noch nicht geöffnet. Der Dialog zeigt deshalb keinen Preis und keinen Rabatt." };
|
||||||
|
}
|
||||||
|
return { ok: true, text: "Derzeit einlösbar (10 % auf den gültigen Preis)." };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||||
await connection();
|
await connection();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -17,11 +39,13 @@ export default async function VoucherPage({ params, searchParams }: { params: Pr
|
|||||||
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
||||||
});
|
});
|
||||||
if (!voucher) notFound();
|
if (!voucher) notFound();
|
||||||
|
const check = await redeemability(voucher.code);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
||||||
<PageTitle>{voucher.code}</PageTitle>
|
<PageTitle>{voucher.code}</PageTitle>
|
||||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||||
|
<p className={`mb-6 rounded-lg border p-4 ${check.ok ? "border-line" : "border-rosso/40 text-rosso"}`}>{check.text}</p>
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
<VoucherForm voucher={voucher} />
|
<VoucherForm voucher={voucher} />
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
import { getAuth } from "@/lib/auth";
|
import { getAuth } from "@/lib/auth";
|
||||||
|
import { withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).GET(request);
|
return toNextJsHandler(getAuth()).GET(await withClientIp(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).POST(request);
|
return toNextJsHandler(getAuth()).POST(await withClientIp(request));
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||||
import { abandonCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
import { abandonCheckout, installmentPaymentUrl } from "@/lib/checkout";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
import { requireAppSecret } from "@/lib/env";
|
|
||||||
import { getGateway } from "@/lib/payment-gateway";
|
import { getGateway } from "@/lib/payment-gateway";
|
||||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
||||||
@@ -37,7 +36,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
switch (body.data.action) {
|
switch (body.data.action) {
|
||||||
case "pay_installment": {
|
case "pay_installment": {
|
||||||
const result = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId }, { now, secret: requireAppSecret() });
|
const result = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId });
|
||||||
if (result.ok) return ok({ url: result.url });
|
if (result.ok) return ok({ url: result.url });
|
||||||
return result.error === "NOT_PAYABLE"
|
return result.error === "NOT_PAYABLE"
|
||||||
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Formular "Erstgespraech" (src/components/sections/ContactSection.tsx).
|
||||||
|
* Gleiche Schutzschicht wie die Buchung: nur JSON von der eigenen Seite,
|
||||||
|
* Rate Limit je IP, serverseitige Pruefung aller Felder.
|
||||||
|
*/
|
||||||
|
import { after } from "next/server";
|
||||||
|
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||||
|
import { contactRequestSchema, submitContactRequest } from "@/lib/contact";
|
||||||
|
import { getDb } from "@/lib/db";
|
||||||
|
import { flushEmails } from "@/lib/email";
|
||||||
|
import { requireAppSecret } from "@/lib/env";
|
||||||
|
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const blocked = checkSameOrigin(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
const limited = await rateLimited(request, RATE_LIMITS.contact);
|
||||||
|
if (limited) return limited;
|
||||||
|
const body = await readJson(request, contactRequestSchema);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const result = await submitContactRequest(db, body.data, { now: new Date(), secret: requireAppSecret() });
|
||||||
|
if (!result.ok) {
|
||||||
|
return fail(503, { code: "NOT_CONFIGURED", message: "Das Formular ist gerade nicht verfügbar. Bitte schreib uns direkt eine E-Mail." });
|
||||||
|
}
|
||||||
|
// Sofort zustellen statt auf den naechsten Worker-Lauf zu warten; schlaegt
|
||||||
|
// das fehl, versucht es der Worker erneut.
|
||||||
|
if (result.delivered) {
|
||||||
|
after(() => flushEmails(db, { limit: 5 }).catch(() => undefined));
|
||||||
|
}
|
||||||
|
return ok({ received: true });
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export default function DatenschutzPage() {
|
|||||||
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
|
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
|
||||||
<li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li>
|
<li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li>
|
||||||
<li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
|
<li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
|
||||||
|
<li>Formular Erstgespräch: Name, E-Mail, optional Telefon, Erreichbarkeit und Nachricht; wird nicht in einer eigenen Tabelle gespeichert, sondern als E-Mail an den Veranstalter geschickt (Kopie im Postausgang der Datenbank).</li>
|
||||||
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li>
|
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li>
|
||||||
<li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li>
|
<li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li>
|
||||||
<li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
|
<li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: 5rem;
|
scroll-padding-top: 5.5rem;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
scrollbar-color: var(--color-stone) var(--color-plaster);
|
scrollbar-color: var(--color-stone) var(--color-plaster);
|
||||||
accent-color: var(--color-rosso);
|
accent-color: var(--color-rosso);
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ path
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": type,
|
"Content-Type": type,
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Cache-Control": "public, max-age=86400",
|
// Mit ?v=<Aenderungsdatum> (site-media.ts) aendert sich die Adresse bei
|
||||||
|
// jedem Austausch: dann ein Jahr cachen, sonst einen Tag.
|
||||||
|
"Cache-Control": new URL(request.url).searchParams.has("v") ? "public, max-age=31536000, immutable" : "public, max-age=86400",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
// SVG (Logo) direkt aufgerufen: keine Skripte, keine Nachladungen.
|
// SVG (Logo) direkt aufgerufen: keine Skripte, keine Nachladungen.
|
||||||
...(type === "image/svg+xml" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}),
|
...(type === "image/svg+xml" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}),
|
||||||
|
|||||||
+5
-1
@@ -6,6 +6,8 @@ import { AccommodationSection } from "@/components/sections/CampSections";
|
|||||||
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
|
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
|
||||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||||
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
|
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
|
||||||
|
import { ContactSection } from "@/components/sections/ContactSection";
|
||||||
|
import { IntroVideoSection } from "@/components/sections/IntroVideoSection";
|
||||||
import { PricingSection } from "@/components/sections/PricingSection";
|
import { PricingSection } from "@/components/sections/PricingSection";
|
||||||
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
@@ -16,7 +18,7 @@ import { loadPublicPage } from "@/lib/public-page";
|
|||||||
import { loadSiteMedia } from "@/lib/site-media";
|
import { loadSiteMedia } from "@/lib/site-media";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Gliederung nach der Filmcamp-Referenz: Headerbild, Termin, was dich
|
* Gliederung nach der Filmcamp-Referenz: Headerbild, Video, Termin, was dich
|
||||||
* erwartet (die Villa), Tagesablauf, Unterkunft, Preis, Team, Reels,
|
* erwartet (die Villa), Tagesablauf, Unterkunft, Preis, Team, Reels,
|
||||||
* Fragen, Abschluss.
|
* Fragen, Abschluss.
|
||||||
*/
|
*/
|
||||||
@@ -37,6 +39,7 @@ export default async function HomePage() {
|
|||||||
<SiteHeader logoLight={media.logoLight} logoDark={media.logoDark} />
|
<SiteHeader logoLight={media.logoLight} logoDark={media.logoDark} />
|
||||||
<main id="inhalt">
|
<main id="inhalt">
|
||||||
<Hero pricing={pricing} media={media.header} video={media.headerVideo} />
|
<Hero pricing={pricing} media={media.header} video={media.headerVideo} />
|
||||||
|
<IntroVideoSection video={media.intro} showDrafts={showDrafts} />
|
||||||
<DateBar facts={facts} />
|
<DateBar facts={facts} />
|
||||||
<ExpectSection />
|
<ExpectSection />
|
||||||
<ScheduleSection facts={facts} />
|
<ScheduleSection facts={facts} />
|
||||||
@@ -47,6 +50,7 @@ export default async function HomePage() {
|
|||||||
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
||||||
<AboutSection showDrafts={showDrafts} />
|
<AboutSection showDrafts={showDrafts} />
|
||||||
<FaqSection facts={facts} pricing={pricing} />
|
<FaqSection facts={facts} pricing={pricing} />
|
||||||
|
<ContactSection />
|
||||||
<ClosingSection pricing={pricing} />
|
<ClosingSection pricing={pricing} />
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} />
|
<SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} />
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function Hero({ pricing, media, video }: { pricing: PublicPricing; media:
|
|||||||
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgb(35_33_31/0.6)_0%,rgb(35_33_31/0.45)_45%,rgb(35_33_31/0.75)_100%)]" />
|
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgb(35_33_31/0.6)_0%,rgb(35_33_31/0.45)_45%,rgb(35_33_31/0.75)_100%)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container-edge flex flex-col items-center pb-20 pt-32 text-center">
|
<div className="container-edge flex flex-col items-center pb-20 pt-44 text-center md:pt-60">
|
||||||
<h1 id="hero-title" className="hero-fade max-w-4xl text-[clamp(2.75rem,8vw,6.25rem)] font-extrabold leading-[0.98] tracking-[-0.03em]">
|
<h1 id="hero-title" className="hero-fade max-w-4xl text-[clamp(2.75rem,8vw,6.25rem)] font-extrabold leading-[0.98] tracking-[-0.03em]">
|
||||||
<span className="sr-only">ARTISTA </span>
|
<span className="sr-only">ARTISTA </span>
|
||||||
{hero.title}
|
{hero.title}
|
||||||
|
|||||||
+26
-7
@@ -1,12 +1,31 @@
|
|||||||
|
import type { LogoImage } from "@/lib/site-media";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ARTISTA-Logo aus dem Medienverzeichnis (logo.svg/png bzw. logo-hell.*).
|
* Umfaerbung, wenn keine passende Fassung hochgeladen wurde (siehe
|
||||||
* Ohne Datei erscheint ein Schriftzug. Bewusst <img> statt next/image:
|
* resolveLogos in site-media.ts): ein helles Logo wird auf heller Flaeche
|
||||||
* SVG-Logos laufen nicht durch die Bildoptimierung.
|
* dunkel, ein dunkles auf Foto weiss.
|
||||||
*/
|
*/
|
||||||
export function Logo({ src, className = "" }: { src: string | null; className?: string }) {
|
const TONE_FILTER: Record<LogoImage["tone"], string> = {
|
||||||
if (src) {
|
"as-is": "",
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
darken: "brightness(0.28) contrast(1.2)",
|
||||||
return <img src={src} alt="ARTISTA" className={`h-full w-auto ${className}`} />;
|
lighten: "brightness(0) invert(1)",
|
||||||
|
};
|
||||||
|
/** Ueber dem Foto hebt ein weicher Schatten das Logo vom Bild ab. */
|
||||||
|
const PHOTO_SHADOW = "drop-shadow(0 2px 14px rgb(0 0 0 / 0.55))";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARTISTA-Logo aus dem Medienverzeichnis (logo.* bzw. logo-hell.*). Ohne
|
||||||
|
* Datei erscheint ein Schriftzug. Bewusst <img> statt next/image: die URL
|
||||||
|
* ist bereits optimiert (Raster) bzw. ein SVG, und die Hoehe kommt vom
|
||||||
|
* Elternelement.
|
||||||
|
*/
|
||||||
|
export function Logo({ logo, onPhoto = false, className = "" }: { logo: LogoImage | null; onPhoto?: boolean; className?: string }) {
|
||||||
|
if (logo) {
|
||||||
|
const filter = [TONE_FILTER[logo.tone], onPhoto ? PHOTO_SHADOW : ""].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={logo.src} alt="ARTISTA Modelcamp 2027" style={filter ? { filter } : undefined} className={`h-full w-auto ${className}`} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <span className={`font-bold uppercase tracking-[0.32em] ${className}`}>Artista</span>;
|
return <span className={`font-bold uppercase tracking-[0.32em] ${className}`}>Artista</span>;
|
||||||
}
|
}
|
||||||
@@ -1,50 +1,119 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ScheduleItem } from "@/content/site";
|
import type { ScheduleItem } from "@/content/site";
|
||||||
|
|
||||||
export type ScheduleCard = { label: string; date: string | null; title: string; items: ScheduleItem[] };
|
export type ScheduleCard = { label: string; date: string | null; title: string; items: ScheduleItem[] };
|
||||||
|
|
||||||
|
/** Toleranz in Pixeln fuer "ganz am Anfang/Ende" (Rundung beim Einrasten). */
|
||||||
|
const EDGE_TOLERANCE = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tagesablauf als waagerechte Kartenreihe mit Einrasten (scroll-snap).
|
* Tagesablauf als waagerechte Kartenreihe mit Einrasten (scroll-snap).
|
||||||
* Wischen auf dem Smartphone, Pfeile und Punkte auf allen Groessen. Die
|
* Wischen auf dem Smartphone, Pfeile und Punkte auf allen Groessen. Die
|
||||||
* Liste bleibt eine echte Liste: ohne JavaScript scrollt sie trotzdem.
|
* Liste bleibt eine echte Liste: ohne JavaScript scrollt sie trotzdem.
|
||||||
|
*
|
||||||
|
* Positionen werden aus getBoundingClientRect berechnet, nicht aus
|
||||||
|
* offsetLeft: offsetLeft bezieht sich auf das naechste positionierte
|
||||||
|
* Element (hier <body>) und verfehlte den Einrastpunkt, sodass "Weiter"
|
||||||
|
* zwei Tage sprang und "Zurueck" am Ende haengen blieb.
|
||||||
*/
|
*/
|
||||||
export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
|
export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
|
||||||
const track = useRef<HTMLOListElement>(null);
|
const track = useRef<HTMLOListElement>(null);
|
||||||
const cards = useRef<(HTMLLIElement | null)[]>([]);
|
const cards = useRef<(HTMLLIElement | null)[]>([]);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [atStart, setAtStart] = useState(true);
|
||||||
|
const [atEnd, setAtEnd] = useState(days.length <= 1);
|
||||||
|
|
||||||
|
const snapInset = (root: HTMLElement) => parseFloat(getComputedStyle(root).scrollPaddingLeft) || 0;
|
||||||
|
|
||||||
|
const update = useCallback(() => {
|
||||||
|
const root = track.current;
|
||||||
|
if (!root) return;
|
||||||
|
const anchor = root.getBoundingClientRect().left + snapInset(root);
|
||||||
|
let nearest = 0;
|
||||||
|
let best = Infinity;
|
||||||
|
cards.current.forEach((card, index) => {
|
||||||
|
if (!card) return;
|
||||||
|
const distance = Math.abs(card.getBoundingClientRect().left - anchor);
|
||||||
|
if (distance < best) {
|
||||||
|
best = distance;
|
||||||
|
nearest = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setCurrent(nearest);
|
||||||
|
setAtStart(root.scrollLeft <= EDGE_TOLERANCE);
|
||||||
|
setAtEnd(root.scrollLeft >= root.scrollWidth - root.clientWidth - EDGE_TOLERANCE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = track.current;
|
const root = track.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const observer = new IntersectionObserver(
|
let frame = 0;
|
||||||
(entries) => {
|
const onScroll = () => {
|
||||||
const visible = entries.filter((e) => e.isIntersecting).map((e) => Number((e.target as HTMLElement).dataset.index));
|
cancelAnimationFrame(frame);
|
||||||
if (visible.length) setCurrent(Math.min(...visible));
|
frame = requestAnimationFrame(update);
|
||||||
},
|
};
|
||||||
{ root, threshold: 0.6 },
|
update();
|
||||||
);
|
root.addEventListener("scroll", onScroll, { passive: true });
|
||||||
cards.current.forEach((card) => card && observer.observe(card));
|
window.addEventListener("resize", onScroll);
|
||||||
return () => observer.disconnect();
|
return () => {
|
||||||
}, []);
|
cancelAnimationFrame(frame);
|
||||||
|
root.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("resize", onScroll);
|
||||||
|
};
|
||||||
|
}, [update]);
|
||||||
|
|
||||||
const go = (index: number) => {
|
const go = (index: number) => {
|
||||||
const target = cards.current[Math.max(0, Math.min(days.length - 1, index))];
|
|
||||||
const root = track.current;
|
const root = track.current;
|
||||||
if (!target || !root) return;
|
const target = cards.current[Math.max(0, Math.min(days.length - 1, index))];
|
||||||
|
if (!root || !target) return;
|
||||||
|
const left = root.scrollLeft + target.getBoundingClientRect().left - root.getBoundingClientRect().left - snapInset(root);
|
||||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
root.scrollTo({ left: target.offsetLeft - root.offsetLeft, behavior: reduce ? "auto" : "smooth" });
|
root.scrollTo({ left, behavior: reduce ? "auto" : "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{days.map((day, index) => (
|
||||||
|
<button
|
||||||
|
key={day.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(index)}
|
||||||
|
aria-label={`${day.label}${day.date ? `, ${day.date}` : ""} zeigen`}
|
||||||
|
aria-current={index === current ? "true" : undefined}
|
||||||
|
className="grid h-8 w-6 place-items-center"
|
||||||
|
>
|
||||||
|
<span className={`block h-1.5 rounded-full transition-all duration-300 ${index === current ? "w-5 bg-ink" : "w-1.5 bg-ink/30"}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => go(current - 1)} disabled={atStart} aria-label="Vorheriger Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
|
||||||
|
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => go(current + 1)} disabled={atEnd} aria-label="Nächster Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
|
||||||
|
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ol
|
<ol
|
||||||
ref={track}
|
ref={track}
|
||||||
aria-label="Tage im Camp"
|
aria-label="Tage im Camp"
|
||||||
// Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1.
|
// Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1.
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="-mx-4 flex snap-x snap-mandatory scroll-px-4 gap-4 overflow-x-auto px-4 pb-4 md:scroll-px-10 [scrollbar-width:none] md:-mx-10 md:gap-6 md:px-10 [&::-webkit-scrollbar]:hidden"
|
// Smartphone: Karte 85vw breit und mittig (7,5vw Rand links und
|
||||||
|
// rechts), Nachbarkarten lugen an beiden Seiten hervor.
|
||||||
|
// Ab md: Karten passen vollstaendig in den Inhaltsbereich (2 bzw. 3
|
||||||
|
// nebeneinander), nichts wird am Rand abgeschnitten.
|
||||||
|
className="-mx-4 flex snap-x snap-mandatory scroll-px-[7.5vw] gap-4 overflow-x-auto px-[7.5vw] pb-4 [scrollbar-width:none] sm:scroll-px-4 sm:px-4 md:mx-0 md:scroll-px-0 md:gap-6 md:px-0 [&::-webkit-scrollbar]:hidden"
|
||||||
>
|
>
|
||||||
{days.map((day, index) => (
|
{days.map((day, index) => (
|
||||||
<li
|
<li
|
||||||
@@ -52,8 +121,7 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
cards.current[index] = el;
|
cards.current[index] = el;
|
||||||
}}
|
}}
|
||||||
data-index={index}
|
className="flex w-[85vw] shrink-0 snap-start flex-col rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] sm:w-[62%] md:w-[calc((100%-1.5rem)/2)] md:p-8 lg:w-[calc((100%-3rem)/3)]"
|
||||||
className="flex w-[88%] shrink-0 snap-start flex-col rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] sm:w-[62%] md:w-[46%] md:p-8 lg:w-[34%]"
|
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-rosso">
|
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-rosso">
|
||||||
{day.label} · {day.title}
|
{day.label} · {day.title}
|
||||||
@@ -78,35 +146,6 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-between gap-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{days.map((day, index) => (
|
|
||||||
<button
|
|
||||||
key={day.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => go(index)}
|
|
||||||
aria-label={`${day.label}${day.date ? `, ${day.date}` : ""} zeigen`}
|
|
||||||
aria-current={index === current ? "true" : undefined}
|
|
||||||
className="grid h-8 w-6 place-items-center"
|
|
||||||
>
|
|
||||||
<span className={`block h-1.5 rounded-full transition-all duration-300 ${index === current ? "w-5 bg-ink" : "w-1.5 bg-ink/30"}`} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button type="button" onClick={() => go(current - 1)} disabled={current === 0} aria-label="Vorheriger Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
|
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
|
|
||||||
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => go(current + 1)} disabled={current >= days.length - 1} aria-label="Nächster Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
|
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
|
|
||||||
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { PublicCampFacts } from "@/lib/camp";
|
import type { PublicCampFacts } from "@/lib/camp";
|
||||||
|
import type { LogoImage } from "@/lib/site-media";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts; showDrafts: boolean; logo: string | null }) {
|
export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts; showDrafts: boolean; logo: LogoImage | null }) {
|
||||||
const organizer = facts.organizer;
|
const organizer = facts.organizer;
|
||||||
return (
|
return (
|
||||||
<footer id="site-footer" className="border-t border-line bg-plaster">
|
<footer id="site-footer" className="border-t border-line bg-plaster">
|
||||||
<div className="container-edge grid gap-10 py-14 md:grid-cols-12">
|
<div className="container-edge grid gap-10 py-14 md:grid-cols-12">
|
||||||
<div className="md:col-span-5">
|
<div className="md:col-span-5">
|
||||||
<p className="flex h-10 items-center text-2xl">
|
<p className="flex h-32 items-center text-2xl">
|
||||||
<Logo src={logo} />
|
<Logo logo={logo} />
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { LazyMotion, domAnimation, m } from "motion/react";
|
import { LazyMotion, domAnimation, m } from "motion/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { NAV_ITEMS } from "@/content/site";
|
import { NAV_ITEMS } from "@/content/site";
|
||||||
|
import type { LogoImage } from "@/lib/site-media";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
const LEFT = NAV_ITEMS.slice(0, 3);
|
const LEFT = NAV_ITEMS.slice(0, 3);
|
||||||
@@ -15,7 +16,7 @@ type NavItem = (typeof NAV_ITEMS)[number];
|
|||||||
* rechts. Ueber dem Headerbild transparent mit heller Schrift, nach dem
|
* rechts. Ueber dem Headerbild transparent mit heller Schrift, nach dem
|
||||||
* Scrollen auf hellem Grund.
|
* Scrollen auf hellem Grund.
|
||||||
*/
|
*/
|
||||||
export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null; logoDark: string | null }) {
|
export function SiteHeader({ logoLight, logoDark }: { logoLight: LogoImage | null; logoDark: LogoImage | null }) {
|
||||||
const [active, setActive] = useState<string | null>(null);
|
const [active, setActive] = useState<string | null>(null);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -82,7 +83,9 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
|
|||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
<div className="container-edge grid h-[4.5rem] grid-cols-[1fr_auto_1fr] items-center gap-4">
|
<div
|
||||||
|
className={`container-edge grid grid-cols-[1fr_auto_1fr] items-center gap-4 transition-[height] duration-300 ${solid ? "h-24" : "h-32 md:h-48"}`}
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
ref={menuButton}
|
ref={menuButton}
|
||||||
@@ -99,8 +102,13 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="#top" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang" className="flex h-8 items-center text-xl md:h-10 md:text-2xl">
|
{/* Logo ueber dem Headerbild gross, nach dem Scrollen kompakter. */}
|
||||||
<Logo src={solid ? logoDark : logoLight} />
|
<a
|
||||||
|
href="#top"
|
||||||
|
aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang"
|
||||||
|
className={`flex items-center text-xl transition-[height] duration-300 md:text-2xl ${solid ? "h-20" : "h-28 md:h-44"}`}
|
||||||
|
>
|
||||||
|
<Logo logo={solid ? logoDark : logoLight} onPhoto={!solid} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-7">
|
<div className="flex items-center justify-end gap-7">
|
||||||
|
|||||||
@@ -1,47 +1,49 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { Media } from "@/content/site";
|
import type { Media } from "@/content/site";
|
||||||
|
import { optimizedImageUrl } from "@/lib/image-url";
|
||||||
import { MediaFrame } from "./MediaFrame";
|
import { MediaFrame } from "./MediaFrame";
|
||||||
|
|
||||||
|
/** Reels sind hoch und hoechstens ein Drittel breit. */
|
||||||
|
const POSTER_WIDTH = 1080;
|
||||||
|
|
||||||
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
|
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Reel im Hochformat (9:16) aus dem eigenen Medienverzeichnis. Das
|
* Ein Video aus dem eigenen Medienverzeichnis, im eigenen Seitenverhaeltnis
|
||||||
* Video laedt erst nach Klick (preload="none") und startet nie von selbst
|
* (quer oder hoch). Es startet nie von selbst. Mit Vorschaubild laedt vorab
|
||||||
* mit Ton. Kein externer Anbieter, keine Datenuebertragung an Dritte.
|
* nichts vom Video, ohne nur Metadaten und erstes Bild (#t=0.1). Kein
|
||||||
|
* externer Anbieter.
|
||||||
*/
|
*/
|
||||||
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
|
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
|
||||||
const [playing, setPlaying] = useState(false);
|
const label = slot.name ? `Video von ${slot.name}` : `Video ${index + 1}`;
|
||||||
const label = slot.name ? `Reel von ${slot.name} abspielen` : `Reel ${index + 1} abspielen`;
|
// Vorschaubild ueber die Bildoptimierung statt im Original (ein Reel-PNG
|
||||||
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Reel ${index + 1} folgt`, tone: "studio" };
|
// hatte 1,3 MB); mit Vorschaubild laedt vom Video vorab nichts.
|
||||||
|
const poster = slot.poster?.kind === "image" ? optimizedImageUrl(slot.poster.src, POSTER_WIDTH) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure>
|
<figure>
|
||||||
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl bg-ink">
|
{slot.src ? (
|
||||||
{playing && slot.src ? (
|
<video
|
||||||
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
|
src={poster ? slot.src : `${slot.src}#t=0.1`}
|
||||||
<track kind="captions" />
|
poster={poster}
|
||||||
</video>
|
controls
|
||||||
) : (
|
playsInline
|
||||||
<>
|
preload={poster ? "none" : "metadata"}
|
||||||
<MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 640px) 33vw, 100vw" showLabel={showDrafts} />
|
aria-label={label}
|
||||||
{slot.src && (
|
className="mx-auto block max-h-[80vh] w-full rounded-2xl bg-ink object-contain"
|
||||||
<button
|
>
|
||||||
type="button"
|
<track kind="captions" />
|
||||||
onClick={() => setPlaying(true)}
|
</video>
|
||||||
aria-label={label}
|
) : (
|
||||||
className="absolute inset-0 m-auto flex h-20 w-20 items-center justify-center rounded-full bg-paper/90 text-ink transition-transform hover:scale-105"
|
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl">
|
||||||
>
|
<MediaFrame
|
||||||
<svg viewBox="0 0 24 24" className="ml-1 h-8 w-8" aria-hidden>
|
media={slot.poster ?? { kind: "placeholder", label: `Video ${index + 1} folgt`, tone: "studio" }}
|
||||||
<path d="M7 4.5v15l12-7.5z" fill="currentColor" />
|
aspect="h-full w-full"
|
||||||
</svg>
|
sizes="(min-width: 640px) 33vw, 100vw"
|
||||||
</button>
|
showLabel={showDrafts}
|
||||||
)}
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{(slot.name || slot.caption || (showDrafts && !slot.src)) && (
|
||||||
{(slot.name || slot.caption || showDrafts) && (
|
|
||||||
<figcaption className="mt-4">
|
<figcaption className="mt-4">
|
||||||
{slot.name ? <span className="block font-semibold">{slot.name}</span> : showDrafts && <span className="draft-flag">Name und Freigabe folgen</span>}
|
{slot.name ? <span className="block font-semibold">{slot.name}</span> : showDrafts && <span className="draft-flag">Name und Freigabe folgen</span>}
|
||||||
{slot.caption && <span className="mt-1 block opacity-80">{slot.caption}</span>}
|
{slot.caption && <span className="mt-1 block opacity-80">{slot.caption}</span>}
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export function BookingDialog({
|
|||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
||||||
const [chosenPlan, setPlanType] = useState<PlanType>("FULL");
|
// Keine Vorauswahl: Preise erscheinen erst unter der gewaehlten Zahlungsweise.
|
||||||
|
const [chosenPlan, setPlanType] = useState<PlanType | null>(null);
|
||||||
const [voucherInput, setVoucherInput] = useState("");
|
const [voucherInput, setVoucherInput] = useState("");
|
||||||
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
||||||
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
||||||
@@ -114,11 +115,11 @@ export function BookingDialog({
|
|||||||
};
|
};
|
||||||
}, [open, quote]);
|
}, [open, quote]);
|
||||||
|
|
||||||
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck.
|
// Eine nicht (mehr) verfuegbare Variante gilt als nicht gewaehlt.
|
||||||
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
||||||
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan;
|
const planType: PlanType | null = chosen?.available ? chosenPlan : null;
|
||||||
|
|
||||||
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null;
|
const selectedPlan = chosen?.available ? chosen : null;
|
||||||
|
|
||||||
function updateParticipant(field: keyof Participant, value: string) {
|
function updateParticipant(field: keyof Participant, value: string) {
|
||||||
setParticipant((prev) => ({ ...prev, [field]: value }));
|
setParticipant((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -174,6 +175,7 @@ export function BookingDialog({
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
if (!planType) return;
|
||||||
const missing: FieldErrors = {};
|
const missing: FieldErrors = {};
|
||||||
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
||||||
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
||||||
@@ -324,32 +326,29 @@ export function BookingDialog({
|
|||||||
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
{quote?.plans.map((plan) => (
|
{quote?.plans.map((plan) => (
|
||||||
<label
|
<div
|
||||||
key={plan.type}
|
key={plan.type}
|
||||||
className={`flex cursor-pointer gap-4 rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "cursor-not-allowed opacity-60"}`}
|
className={`rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "opacity-60"}`}
|
||||||
>
|
>
|
||||||
<input
|
<label className={`flex gap-4 ${plan.available ? "cursor-pointer" : "cursor-not-allowed"}`}>
|
||||||
type="radio"
|
<input
|
||||||
name="planType"
|
type="radio"
|
||||||
value={plan.type}
|
name="planType"
|
||||||
checked={planType === plan.type}
|
value={plan.type}
|
||||||
disabled={!plan.available}
|
checked={planType === plan.type}
|
||||||
onChange={() => {
|
disabled={!plan.available}
|
||||||
setPlanType(plan.type);
|
onChange={() => {
|
||||||
}}
|
setPlanType(plan.type);
|
||||||
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
|
}}
|
||||||
/>
|
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
|
||||||
<span className="flex-1">
|
/>
|
||||||
<span className="block font-semibold">{plan.label}</span>
|
<span className="flex-1">
|
||||||
{plan.available ? (
|
<span className="block font-semibold">{plan.label}</span>
|
||||||
<span className="mt-1 block text-[0.9375rem] text-ink/80">
|
{!plan.available && <span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>}
|
||||||
{plan.installments.map((i) => money(i.amountCents)).join(" + ")}
|
</span>
|
||||||
</span>
|
</label>
|
||||||
) : (
|
{plan.available && planType === plan.type && quote.price && <PlanDetails plan={plan} quote={quote} />}
|
||||||
<span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>
|
</div>
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -384,8 +383,6 @@ export function BookingDialog({
|
|||||||
{voucherMessage}
|
{voucherMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quote?.price && <PriceBreakdown quote={quote} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -462,7 +459,7 @@ export function BookingDialog({
|
|||||||
</a>{" "}
|
</a>{" "}
|
||||||
gelesen.
|
gelesen.
|
||||||
</Consent>
|
</Consent>
|
||||||
{planType !== "FULL" && (
|
{planType !== null && planType !== "FULL" && (
|
||||||
<Consent
|
<Consent
|
||||||
id="installmentMandate"
|
id="installmentMandate"
|
||||||
checked={consents.installmentMandate}
|
checked={consents.installmentMandate}
|
||||||
@@ -494,7 +491,7 @@ export function BookingDialog({
|
|||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
{step < STEPS.length - 1 ? (
|
{step < STEPS.length - 1 ? (
|
||||||
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}>
|
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && (!quote?.price || !selectedPlan)}>
|
||||||
Weiter
|
Weiter
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -509,6 +506,27 @@ export function BookingDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preis und Raten unter der gewaehlten Zahlungsweise. */
|
||||||
|
function PlanDetails({ plan, quote }: { plan: QuoteData["plans"][number]; quote: QuoteData }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 pl-9">
|
||||||
|
<PriceBreakdown quote={quote} />
|
||||||
|
{plan.installments.length > 1 && (
|
||||||
|
<ul className="mt-3 space-y-1 text-[0.9375rem] text-ink/80">
|
||||||
|
{plan.installments.map((i) => (
|
||||||
|
<li key={i.seq} className="flex justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
Rate {i.seq}, {i.dueDate ? `fällig ${isoDate(i.dueDate)}` : "heute"}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">{money(i.amountCents)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
||||||
const p = quote.price!;
|
const p = quote.price!;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "error"; message: string };
|
||||||
|
type Fields = Record<string, string>;
|
||||||
|
|
||||||
|
const FIELD_IDS = ["name", "email", "phone", "availability", "message", "privacy"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anfrage fuer ein unverbindliches Erstgespraech (POST /api/contact). Die
|
||||||
|
* Pruefung passiert auf dem Server; Fehler erscheinen am jeweiligen Feld,
|
||||||
|
* der Fokus springt auf das erste fehlerhafte. Das Feld "website" ist eine
|
||||||
|
* fuer Menschen unsichtbare Falle fuer Bots.
|
||||||
|
*/
|
||||||
|
export function ContactSection() {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [status, setStatus] = useState<Status>({ kind: "idle" });
|
||||||
|
const [errors, setErrors] = useState<Fields>({});
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const text = (key: string) => String(form.get(key) ?? "");
|
||||||
|
setStatus({ kind: "sending" });
|
||||||
|
setErrors({});
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email"),
|
||||||
|
phone: text("phone"),
|
||||||
|
availability: text("availability"),
|
||||||
|
message: text("message"),
|
||||||
|
privacy: form.get("privacy") === "on",
|
||||||
|
website: text("website"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = (await response.json().catch(() => null)) as { success: boolean; error?: { message: string; fields?: Fields } } | null;
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
setStatus({ kind: "sent" });
|
||||||
|
formRef.current?.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields = result?.error?.fields ?? {};
|
||||||
|
setErrors(fields);
|
||||||
|
setStatus({ kind: "error", message: result?.error?.message ?? "Senden hat nicht geklappt. Bitte versuche es erneut." });
|
||||||
|
const first = FIELD_IDS.find((id) => fields[id]);
|
||||||
|
if (first) requestAnimationFrame(() => document.getElementById(`ct-${first}`)?.focus());
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: "error", message: "Keine Verbindung. Bitte prüfe dein Internet und versuche es erneut." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldProps = (id: (typeof FIELD_IDS)[number]) => ({
|
||||||
|
id: `ct-${id}`,
|
||||||
|
name: id,
|
||||||
|
"aria-invalid": errors[id] ? true : undefined,
|
||||||
|
"aria-describedby": errors[id] ? `ct-${id}-error` : undefined,
|
||||||
|
});
|
||||||
|
const error = (id: string) =>
|
||||||
|
errors[id] ? (
|
||||||
|
<p id={`ct-${id}-error`} className="mt-1.5 text-sm font-medium text-rosso">
|
||||||
|
{errors[id]}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="erstgespraech" aria-labelledby="erstgespraech-title" className="bg-plaster py-24 md:py-32">
|
||||||
|
<div className="container-edge grid gap-12 md:grid-cols-12">
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<h2 id="erstgespraech-title" className="section-title">
|
||||||
|
Erstgespräch
|
||||||
|
</h2>
|
||||||
|
<p className="mt-6 max-w-md text-lg text-ink/80">
|
||||||
|
Du bist dir noch unsicher, ob das Modelcamp zu dir passt? Lass uns unverbindlich sprechen. Schreib uns kurz, wann du erreichbar bist, wir melden uns bei dir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-7">
|
||||||
|
{status.kind === "sent" ? (
|
||||||
|
<div role="status" className="rounded-2xl bg-paper p-8 shadow-[0_1px_0_var(--color-line)]">
|
||||||
|
<p className="text-xl font-bold">Danke, deine Anfrage ist angekommen.</p>
|
||||||
|
<p className="mt-2 text-ink/80">Wir melden uns in den nächsten Tagen bei dir.</p>
|
||||||
|
<button type="button" onClick={() => setStatus({ kind: "idle" })} className="btn btn-quiet mt-6">
|
||||||
|
Weitere Anfrage senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form ref={formRef} onSubmit={submit} noValidate className="relative grid gap-5 rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] md:grid-cols-2 md:p-8">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-name">Name</label>
|
||||||
|
<input {...fieldProps("name")} className="field" autoComplete="name" required maxLength={120} />
|
||||||
|
{error("name")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-email">E-Mail</label>
|
||||||
|
<input {...fieldProps("email")} className="field" type="email" autoComplete="email" inputMode="email" required maxLength={200} />
|
||||||
|
{error("email")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-phone">Telefon</label>
|
||||||
|
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" required maxLength={40} />
|
||||||
|
{error("phone")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-availability">Wann bist du erreichbar? (optional)</label>
|
||||||
|
<input {...fieldProps("availability")} className="field" placeholder="z. B. werktags ab 18 Uhr" maxLength={200} />
|
||||||
|
{error("availability")}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="field-label" htmlFor="ct-message">Worüber möchtest du sprechen? (optional)</label>
|
||||||
|
<textarea {...fieldProps("message")} className="field min-h-32" rows={4} maxLength={2000} />
|
||||||
|
{error("message")}
|
||||||
|
</div>
|
||||||
|
{/* Falle fuer Bots: nicht sichtbar, nicht per Tab erreichbar. */}
|
||||||
|
<div aria-hidden className="absolute -left-[9999px] h-px w-px overflow-hidden">
|
||||||
|
<label htmlFor="ct-website">Webseite</label>
|
||||||
|
<input id="ct-website" name="website" tabIndex={-1} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label htmlFor="ct-privacy" className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
|
||||||
|
<input {...fieldProps("privacy")} type="checkbox" required className="mt-0.5 h-5 w-5 shrink-0 accent-ink" />
|
||||||
|
<span>
|
||||||
|
Ich bin einverstanden, dass meine Angaben zur Bearbeitung der Anfrage verwendet werden (
|
||||||
|
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
|
||||||
|
Datenschutzhinweise
|
||||||
|
</a>
|
||||||
|
).
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{error("privacy")}
|
||||||
|
</div>
|
||||||
|
{status.kind === "error" && (
|
||||||
|
<p role="alert" className="rounded-lg border border-rosso/40 bg-plaster p-4 text-[0.9375rem] font-medium text-rosso md:col-span-2">
|
||||||
|
{status.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<button type="submit" disabled={status.kind === "sending"} className="btn btn-primary px-8 disabled:opacity-60">
|
||||||
|
{status.kind === "sending" ? "Wird gesendet …" : "Erstgespräch anfragen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { optimizedImageUrl } from "@/lib/image-url";
|
||||||
|
import type { IntroVideo } from "@/lib/site-media";
|
||||||
|
|
||||||
|
/** Breite des Vorschaubilds: scharf bis zur vollen Inhaltsbreite. */
|
||||||
|
const POSTER_WIDTH = 1920;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video direkt unter dem Headerbild (intro.mp4 im Medienverzeichnis,
|
||||||
|
* Vorschaubild intro.jpg, docs/MEDIEN.md). Startet nur nach Klick. Mit
|
||||||
|
* Vorschaubild laedt der Browser vorher gar nichts vom Video
|
||||||
|
* (preload="none"); ohne wird nur das erste Bild als Vorschau geholt.
|
||||||
|
*/
|
||||||
|
export function IntroVideoSection({ video, showDrafts }: { video: IntroVideo; showDrafts: boolean }) {
|
||||||
|
if (!video.src) {
|
||||||
|
if (!showDrafts) return null;
|
||||||
|
return (
|
||||||
|
<section aria-label="Video" className="bg-ink py-12 text-paper/70 md:py-16">
|
||||||
|
<p className="container-edge text-center">
|
||||||
|
<span className="draft-flag">Platzhalter:</span> Hier erscheint das Video, sobald <code>intro.mp4</code> im Medienverzeichnis liegt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poster = video.poster?.kind === "image" ? optimizedImageUrl(video.poster.src, POSTER_WIDTH) : undefined;
|
||||||
|
return (
|
||||||
|
<section aria-label="Video" className="bg-ink py-12 md:py-20">
|
||||||
|
<div className="container-edge">
|
||||||
|
<video
|
||||||
|
src={poster ? video.src : `${video.src}#t=0.1`}
|
||||||
|
poster={poster}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload={poster ? "none" : "metadata"}
|
||||||
|
aria-label="Video ARTISTA Modelcamp"
|
||||||
|
// Groesse folgt dem Video (quer oder hoch): ohne volle Breite keine
|
||||||
|
// schwarzen Balken neben einem Hochformat-Video.
|
||||||
|
className="mx-auto block h-auto max-h-[85vh] w-auto max-w-full rounded-2xl bg-black"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export function TeamSection({ showDrafts, portraits }: { showDrafts: boolean; po
|
|||||||
|
|
||||||
<ul className="mt-20 grid grid-cols-2 gap-x-4 gap-y-12 md:mt-28 md:grid-cols-4 md:gap-x-8 md:gap-y-16">
|
<ul className="mt-20 grid grid-cols-2 gap-x-4 gap-y-12 md:mt-28 md:grid-cols-4 md:gap-x-8 md:gap-y-16">
|
||||||
{team.people.map((person: TeamMember, index) => (
|
{team.people.map((person: TeamMember, index) => (
|
||||||
<li key={person.slug} className={index % 2 === 1 ? "md:mt-16" : ""}>
|
<li key={person.slug}>
|
||||||
<article aria-label={person.name}>
|
<article aria-label={person.name}>
|
||||||
<Reveal delay={(index % 4) * 0.06}>
|
<Reveal delay={(index % 4) * 0.06}>
|
||||||
<MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} />
|
<MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} />
|
||||||
@@ -95,14 +95,17 @@ export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drei Reels im Hochformat. Leere Slots erscheinen nur in Entwicklung und
|
* Bis zu drei Videos (Models ueber ARTISTA). Ein einzelnes Video steht
|
||||||
* Staging; oeffentlich nie ein erfundenes Testimonial als Ersatz.
|
* breit in der Mitte, mehrere im Raster. Leere Slots erscheinen nur in
|
||||||
|
* Entwicklung und Staging; oeffentlich nie ein erfundenes Testimonial.
|
||||||
*/
|
*/
|
||||||
export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels: Reel[] }) {
|
export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels: Reel[] }) {
|
||||||
const { reels: content } = site;
|
const { reels: content } = site;
|
||||||
const slots = content.slots.map((slot, index) => ({ ...slot, ...(reels[index] ?? { src: null, poster: null }) }));
|
const slots = content.slots.map((slot, index) => ({ ...slot, ...(reels[index] ?? { src: null, poster: null }) }));
|
||||||
const visible = showDrafts ? slots : slots.filter((s) => s.src);
|
const filled = slots.filter((s) => s.src);
|
||||||
|
const visible = showDrafts ? [...filled, ...slots.filter((s) => !s.src)] : filled;
|
||||||
if (visible.length === 0) return null;
|
if (visible.length === 0) return null;
|
||||||
|
const single = visible.length === 1;
|
||||||
return (
|
return (
|
||||||
<section id="reels" aria-labelledby="reels-title" className="on-dark bg-ink py-24 text-paper md:py-32">
|
<section id="reels" aria-labelledby="reels-title" className="on-dark bg-ink py-24 text-paper md:py-32">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
@@ -110,7 +113,7 @@ export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels
|
|||||||
<h2 id="reels-title" className="section-title">{content.title}</h2>
|
<h2 id="reels-title" className="section-title">{content.title}</h2>
|
||||||
<p className="max-w-md text-paper/75">{content.lead}</p>
|
<p className="max-w-md text-paper/75">{content.lead}</p>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mx-auto mt-12 grid max-w-5xl gap-6 sm:grid-cols-3 md:gap-8">
|
<ul className={`mx-auto mt-12 grid gap-6 md:gap-8 ${single ? "max-w-4xl" : "max-w-5xl sm:grid-cols-3"}`}>
|
||||||
{visible.map((slot, index) => (
|
{visible.map((slot, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
<VideoSlot slot={slot} index={index} showDrafts={showDrafts} />
|
<VideoSlot slot={slot} index={index} showDrafts={showDrafts} />
|
||||||
|
|||||||
+5
-4
@@ -193,7 +193,8 @@ export const site = {
|
|||||||
"Eigener Außenpool (saisonal)",
|
"Eigener Außenpool (saisonal)",
|
||||||
"Großer Garten mit Terrasse",
|
"Großer Garten mit Terrasse",
|
||||||
"Blick auf die Berge",
|
"Blick auf die Berge",
|
||||||
"Gemeinsames Wohnzimmer und gut ausgestattete Küche",
|
"Gemeinsames Wohnzimmer",
|
||||||
|
"Tennisplatz",
|
||||||
"Bettwäsche und Handtücher",
|
"Bettwäsche und Handtücher",
|
||||||
"WLAN",
|
"WLAN",
|
||||||
],
|
],
|
||||||
@@ -230,7 +231,6 @@ export const site = {
|
|||||||
{ slug: "lola-stoll", name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") },
|
{ slug: "lola-stoll", name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") },
|
||||||
{ slug: "eileen-kaupp", name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") },
|
{ slug: "eileen-kaupp", name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") },
|
||||||
{ slug: "gabriela-pronkin", name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") },
|
{ slug: "gabriela-pronkin", name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") },
|
||||||
{ slug: "stefan-hoechtl", name: "Stefan Höchtl", role: "Social Media Content Creator", bio: null as string | null, portrait: portrait("Stefan Höchtl", "SH") },
|
|
||||||
{ slug: "saskia-sirowatka", name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") },
|
{ slug: "saskia-sirowatka", name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") },
|
||||||
{ slug: "andre-hartshauser", name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") },
|
{ slug: "andre-hartshauser", name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") },
|
||||||
{ slug: "ante-rejo", name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") },
|
{ slug: "ante-rejo", name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") },
|
||||||
@@ -246,8 +246,8 @@ export const site = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reels: {
|
reels: {
|
||||||
title: "Reels",
|
title: "Stimmen",
|
||||||
lead: "Eindrücke und Stimmen in drei kurzen Clips.",
|
lead: "Models erzählen, wie sie ARTISTA erleben.",
|
||||||
// Genau drei Slots. Video und Vorschaubild kommen ueber das
|
// Genau drei Slots. Video und Vorschaubild kommen ueber das
|
||||||
// Medienverzeichnis (reels/reel-1.mp4 und reels/reel-1.jpg usw.).
|
// Medienverzeichnis (reels/reel-1.mp4 und reels/reel-1.jpg usw.).
|
||||||
// Name und Begleittext nur mit Freigabe der gezeigten Person.
|
// Name und Begleittext nur mit Freigabe der gezeigten Person.
|
||||||
@@ -271,4 +271,5 @@ export const NAV_ITEMS = [
|
|||||||
{ id: "preis", label: "Preis" },
|
{ id: "preis", label: "Preis" },
|
||||||
{ id: "team", label: "Team" },
|
{ id: "team", label: "Team" },
|
||||||
{ id: "fragen", label: "Fragen" },
|
{ id: "fragen", label: "Fragen" },
|
||||||
|
{ id: "erstgespraech", label: "Erstgespräch" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Actor } from "./audit";
|
import type { Actor } from "./audit";
|
||||||
import { getAuth } from "./auth";
|
import { getAuth } from "./auth";
|
||||||
|
import { headersWithClientIp } from "./rate-limit";
|
||||||
|
|
||||||
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ async function currentAdmin(): Promise<AdminUser | null> {
|
|||||||
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
|
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
|
||||||
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
const session = await getAuth().api.getSession({ headers: requestHeaders });
|
const session = await getAuth().api.getSession({ headers: headersWithClientIp(requestHeaders) });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
|
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
|
||||||
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { enqueueEmail } from "./email";
|
|||||||
import { bookingConfirmedEmail } from "./email-templates";
|
import { bookingConfirmedEmail } from "./email-templates";
|
||||||
import type { PaymentGateway } from "./payment-gateway";
|
import type { PaymentGateway } from "./payment-gateway";
|
||||||
import { getCamp } from "./camp";
|
import { getCamp } from "./camp";
|
||||||
|
import { stopCanceledInstallmentPlans } from "./installment-billing";
|
||||||
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
|
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
|
||||||
import { statusTokenFor } from "./tokens";
|
import { statusTokenFor } from "./tokens";
|
||||||
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
|
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
|
||||||
@@ -54,10 +55,17 @@ export async function cancelBooking(
|
|||||||
});
|
});
|
||||||
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
|
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
|
||||||
|
|
||||||
// Offene Stripe-Sessions schliessen und gespeicherte Zahlungsmethode
|
// Offene Session schliessen, Stripe-Ratenplan beenden und gespeicherte
|
||||||
// trennen: nach einer Stornierung darf nichts mehr abgebucht werden.
|
// Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr
|
||||||
const sessions = [before.stripeCheckoutSessionId, ...before.installments.map((i) => i.checkoutSessionId)].filter(Boolean) as string[];
|
// abgebucht werden.
|
||||||
for (const id of sessions) await gateway.expireCheckoutSession(id).catch(() => undefined);
|
if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined);
|
||||||
|
const plan = await stopCanceledInstallmentPlans(db, gateway, [before.id], actor);
|
||||||
|
if (plan.failed > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.",
|
||||||
|
};
|
||||||
|
}
|
||||||
await detachFinishedPaymentMethods(db, gateway, [before.id]);
|
await detachFinishedPaymentMethods(db, gateway, [before.id]);
|
||||||
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
|
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import { NextResponse } from "next/server";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env, requireAppSecret } from "./env";
|
import { env, requireAppSecret } from "./env";
|
||||||
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
|
import { clientIp, consumeRateLimit, rateLimitSubject, type RateLimitRule } from "./rate-limit";
|
||||||
|
|
||||||
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export async function readJson<T extends z.ZodType>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
||||||
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
|
const result = await consumeRateLimit(getDb(), rule, rateLimitSubject(clientIp(request.headers)), requireAppSecret());
|
||||||
if (result.allowed) return null;
|
if (result.allowed) return null;
|
||||||
return fail(
|
return fail(
|
||||||
429,
|
429,
|
||||||
|
|||||||
+4
-1
@@ -17,6 +17,7 @@ import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
|
|||||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { CLIENT_IP_HEADER } from "./rate-limit";
|
||||||
|
|
||||||
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||||
const e = env();
|
const e = env();
|
||||||
@@ -51,7 +52,9 @@ function createAuth(options: { allowSignUp?: boolean } = {}) {
|
|||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
useSecureCookies: e.APP_URL.startsWith("https://"),
|
useSecureCookies: e.APP_URL.startsWith("https://"),
|
||||||
ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] },
|
// Nur die vom Server ermittelte IP (withClientIp im Auth-Endpunkt), nie X-Real-IP
|
||||||
|
// oder X-Forwarded-For direkt: ueber Cloudflare kaemen sie vom Client.
|
||||||
|
ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] },
|
||||||
},
|
},
|
||||||
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
||||||
});
|
});
|
||||||
|
|||||||
+17
-62
@@ -120,77 +120,32 @@ export async function startCheckout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InstallmentCheckoutResult =
|
export type InstallmentPaymentResult = { ok: true; url: string } | { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
|
||||||
| { ok: true; url: string }
|
|
||||||
| { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug, bei
|
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug oder bei
|
||||||
* verlangter Authentifizierung oder freiwillig vorzeitig). Die Rate wird
|
* verlangter Bestaetigung). Bezahlt wird die offene Stripe-Rechnung dieser
|
||||||
* fuer die Laufzeit der Session markiert, damit der Worker sie nicht
|
* Rate ueber Stripes Rechnungsseite — auch mit einer anderen Zahlungsmethode.
|
||||||
* gleichzeitig off-session einzieht.
|
* Eine eigene Checkout-Session gibt es bewusst nicht: Stripe wuerde die
|
||||||
|
* Rechnung sonst parallel weiter einziehen.
|
||||||
*/
|
*/
|
||||||
export async function startInstallmentCheckout(
|
export async function installmentPaymentUrl(
|
||||||
db: Db,
|
db: Db,
|
||||||
gateway: PaymentGateway,
|
gateway: PaymentGateway,
|
||||||
params: { bookingId: string; installmentId: string },
|
params: { bookingId: string; installmentId: string },
|
||||||
ctx: { now: Date; secret: string },
|
): Promise<InstallmentPaymentResult> {
|
||||||
): Promise<InstallmentCheckoutResult> {
|
const installment = await db.installment.findFirst({
|
||||||
const sessionExpiresAt = new Date(ctx.now.getTime() + MIN_STRIPE_SESSION_MS + 60_000);
|
where: { id: params.installmentId, bookingId: params.bookingId, seq: { gt: 1 } },
|
||||||
|
include: { booking: { select: { status: true } } },
|
||||||
const claim = await db.$transaction(async (tx) => {
|
|
||||||
await lockBooking(tx, params.bookingId);
|
|
||||||
const booking = await tx.booking.findUniqueOrThrow({
|
|
||||||
where: { id: params.bookingId },
|
|
||||||
include: { installments: true },
|
|
||||||
});
|
|
||||||
const installment = booking.installments.find((i) => i.id === params.installmentId);
|
|
||||||
if (!installment || installment.seq === 1) return null;
|
|
||||||
if (booking.status !== "CONFIRMED" || !booking.stripeCustomerId) return null;
|
|
||||||
if (!["PENDING", "FAILED", "REQUIRES_ACTION"].includes(installment.status)) return null;
|
|
||||||
if (booking.paidCents + installment.amountCents > booking.totalCents) return null;
|
|
||||||
if (installment.checkoutSessionId && installment.checkoutExpiresAt && installment.checkoutExpiresAt > new Date(ctx.now.getTime() + 5 * 60_000)) {
|
|
||||||
return { booking, installment, existingSessionId: installment.checkoutSessionId };
|
|
||||||
}
|
|
||||||
await tx.installment.update({
|
|
||||||
where: { id: installment.id },
|
|
||||||
data: { checkoutExpiresAt: sessionExpiresAt, checkoutSessionId: null },
|
|
||||||
});
|
|
||||||
return { booking, installment, existingSessionId: null as string | null };
|
|
||||||
});
|
});
|
||||||
if (!claim) return { ok: false, error: "NOT_PAYABLE" };
|
if (!installment?.stripeInvoiceId || installment.booking.status !== "CONFIRMED") return { ok: false, error: "NOT_PAYABLE" };
|
||||||
|
if (installment.status !== "FAILED" && installment.status !== "REQUIRES_ACTION") return { ok: false, error: "NOT_PAYABLE" };
|
||||||
const { booking, installment } = claim;
|
|
||||||
try {
|
try {
|
||||||
if (claim.existingSessionId) {
|
const invoice = await gateway.retrieveInvoice(installment.stripeInvoiceId);
|
||||||
const existing = await gateway.retrieveCheckoutSession(claim.existingSessionId);
|
if (invoice.status !== "open" || !invoice.hostedInvoiceUrl) return { ok: false, error: "NOT_PAYABLE" };
|
||||||
if (existing.status === "open" && existing.url) return { ok: true, url: existing.url };
|
return { ok: true, url: invoice.hostedInvoiceUrl };
|
||||||
}
|
|
||||||
const token = statusTokenFor(booking.id, ctx.secret);
|
|
||||||
const session = await gateway.createCheckoutSession(
|
|
||||||
{
|
|
||||||
customerId: booking.stripeCustomerId!,
|
|
||||||
amountCents: installment.amountCents,
|
|
||||||
productName: PRODUCT_NAME,
|
|
||||||
description: `${PRODUCT_DESCRIPTION} · Rate ${installment.seq} von ${booking.installments.length}`,
|
|
||||||
metadata: { kind: "installment", bookingId: booking.id, reference: booking.reference, installmentId: installment.id },
|
|
||||||
saveForFutureUsage: booking.installments.some((i) => i.seq > installment.seq && i.status !== "PAID"),
|
|
||||||
expiresAt: sessionExpiresAt,
|
|
||||||
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
|
|
||||||
cancelUrl: appUrl(`/buchung/status/${token}`),
|
|
||||||
submitMessage: `Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für Buchung ${booking.reference}.`,
|
|
||||||
},
|
|
||||||
`installment-checkout:${installment.id}:${sessionExpiresAt.getTime()}`,
|
|
||||||
);
|
|
||||||
await db.installment.update({ where: { id: installment.id }, data: { checkoutSessionId: session.id } });
|
|
||||||
await audit(db, CUSTOMER, "checkout.installment_session_created", "Installment", installment.id, { sessionId: session.id });
|
|
||||||
return { ok: true, url: session.url };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[checkout] Stripe-Fehler fuer Rate ${installment.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
console.error(`[checkout] Rechnung fuer Rate ${installment.id} nicht abrufbar: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||||
await db.installment.updateMany({
|
|
||||||
where: { id: installment.id, checkoutSessionId: null },
|
|
||||||
data: { checkoutExpiresAt: null },
|
|
||||||
});
|
|
||||||
return { ok: false, error: "PAYMENT_PROVIDER" };
|
return { ok: false, error: "PAYMENT_PROVIDER" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Anfrage fuer ein unverbindliches Erstgespraech. Kein eigenes Datenmodell:
|
||||||
|
* die Anfrage geht als E-Mail in den Postausgang (EmailMessage, mit
|
||||||
|
* Wiederholungen und Anzeige unzustellbarer Mails im Admin) an den
|
||||||
|
* Veranstalter bzw. ADMIN_NOTIFY_EMAIL.
|
||||||
|
*
|
||||||
|
* Bewusst keine automatische Bestaetigungsmail an die angegebene Adresse:
|
||||||
|
* sonst liesse sich das Formular nutzen, um Dritten Mails zu schicken.
|
||||||
|
*/
|
||||||
|
import "./zod-de";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { audit, CUSTOMER } from "./audit";
|
||||||
|
import { getCamp } from "./camp";
|
||||||
|
import type { Db } from "./db";
|
||||||
|
import { enqueueEmail } from "./email";
|
||||||
|
import { contactInquiryEmail } from "./email-templates";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { hmacHex } from "./tokens";
|
||||||
|
|
||||||
|
const optionalText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v || null);
|
||||||
|
|
||||||
|
export const contactRequestSchema = z.object({
|
||||||
|
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
|
||||||
|
email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
|
||||||
|
// Gleiche Regel wie bei der Buchung.
|
||||||
|
phone: z
|
||||||
|
.string("Bitte gib eine Telefonnummer an.")
|
||||||
|
.trim()
|
||||||
|
.min(6, "Bitte gib eine Telefonnummer an.")
|
||||||
|
.max(40)
|
||||||
|
.regex(/^[+0-9 ()/-]+$/, "Die Telefonnummer darf nur Ziffern, Leerzeichen und + ( ) / - enthalten."),
|
||||||
|
availability: optionalText(200),
|
||||||
|
message: optionalText(2000),
|
||||||
|
privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
|
||||||
|
/** Honeypot: fuer Menschen unsichtbar, Bots fuellen es aus. */
|
||||||
|
website: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactRequest = z.infer<typeof contactRequestSchema>;
|
||||||
|
export type ContactResult = { ok: true; delivered: boolean } | { ok: false; error: "NOT_CONFIGURED" };
|
||||||
|
|
||||||
|
/** Empfaenger: Veranstalter-Adresse aus dem Admin, sonst ADMIN_NOTIFY_EMAIL. */
|
||||||
|
export async function contactRecipient(db: Db): Promise<string | null> {
|
||||||
|
const camp = await getCamp(db);
|
||||||
|
return camp.organizerEmail || env().ADMIN_NOTIFY_EMAIL || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitContactRequest(db: Db, input: ContactRequest, ctx: { now: Date; secret: string }): Promise<ContactResult> {
|
||||||
|
// Bot: so tun, als waere alles gut, aber nichts verschicken.
|
||||||
|
if (input.website) return { ok: true, delivered: false };
|
||||||
|
const to = await contactRecipient(db);
|
||||||
|
if (!to) return { ok: false, error: "NOT_CONFIGURED" };
|
||||||
|
|
||||||
|
// Doppelt abgeschicktes Formular (gleiche Angaben in derselben Minute)
|
||||||
|
// ergibt nur eine Mail.
|
||||||
|
const minute = Math.floor(ctx.now.getTime() / 60_000);
|
||||||
|
const fingerprint = hmacHex(ctx.secret, `contact:${input.email.toLowerCase()}:${input.message ?? ""}:${minute}`).slice(0, 32);
|
||||||
|
await enqueueEmail(db, contactInquiryEmail(to, input, `contact:${fingerprint}`, ctx.now));
|
||||||
|
await audit(db, CUSTOMER, "contact.requested", "Contact", fingerprint);
|
||||||
|
return { ok: true, delivered: true };
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@
|
|||||||
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
|
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
|
||||||
*/
|
*/
|
||||||
import type { Booking, Installment } from "@/generated/prisma/client";
|
import type { Booking, Installment } from "@/generated/prisma/client";
|
||||||
|
import type { ContactRequest } from "./contact";
|
||||||
import { escapeHtml, type EmailDraft } from "./email";
|
import { escapeHtml, type EmailDraft } from "./email";
|
||||||
import { appUrl } from "./env";
|
import { appUrl } from "./env";
|
||||||
import { formatEuro } from "./money";
|
import { formatEuro } from "./money";
|
||||||
import { PLAN_LABEL } from "./payment-plan";
|
import { PLAN_LABEL } from "./payment-plan";
|
||||||
import { PHASE_LABEL } from "./pricing";
|
import { PHASE_LABEL } from "./pricing";
|
||||||
import { formatDate } from "./time";
|
import { formatDate, formatDateTime } from "./time";
|
||||||
|
|
||||||
type BookingWithInstallments = Booking & { installments: Installment[] };
|
type BookingWithInstallments = Booking & { installments: Installment[] };
|
||||||
|
|
||||||
@@ -163,3 +164,20 @@ export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anfrage fuer ein Erstgespraech an den Veranstalter (src/lib/contact.ts). */
|
||||||
|
export function contactInquiryEmail(to: string, inquiry: ContactRequest, dedupeKey: string, receivedAt: Date): EmailDraft {
|
||||||
|
const subject = `Anfrage Erstgespräch: ${inquiry.name}`;
|
||||||
|
const lines = [
|
||||||
|
`Über die Webseite kam eine Anfrage für ein Erstgespräch (${formatDateTime(receivedAt)}).`,
|
||||||
|
[
|
||||||
|
`Name: ${inquiry.name}`,
|
||||||
|
`E-Mail: ${inquiry.email}`,
|
||||||
|
`Telefon: ${inquiry.phone ?? "–"}`,
|
||||||
|
`Erreichbar: ${inquiry.availability ?? "–"}`,
|
||||||
|
].join("\n"),
|
||||||
|
inquiry.message ? `Nachricht:\n${inquiry.message}` : "Keine Nachricht angegeben.",
|
||||||
|
];
|
||||||
|
const reply = `mailto:${encodeURIComponent(inquiry.email)}?subject=${encodeURIComponent(`Dein Erstgespräch – ${BRAND}`)}`;
|
||||||
|
return { dedupeKey, to, subject: `${subject} | ${BRAND}`, ...wrap(subject, lines, { href: reply, label: "Antworten" }) };
|
||||||
|
}
|
||||||
+27
-3
@@ -85,6 +85,28 @@ async function deliver(message: { id: string; toAddress: string; subject: string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECIPIENT_REJECTION_CODES = new Set([550, 551, 553, 556]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endgueltig ist nur eine Ablehnung des Empfaengers (nodemailer EENVELOPE, RCPT TO):
|
||||||
|
* 550/551/553/556 oder 5xx mit Status 5.1.x (Adresse/Domain falsch). Alles andere
|
||||||
|
* (Anmeldung EAUTH, Richtlinie/Blocklist EMESSAGE, 552, 4xx, Verbindung) liegt am
|
||||||
|
* Server oder Absender; die Mail bleibt PENDING und wird bis MAX_EMAIL_ATTEMPTS
|
||||||
|
* wiederholt, damit ein kaputtes SMTP-Secret keine Mails endgueltig verwirft.
|
||||||
|
*/
|
||||||
|
export function isPermanentMailError(error: unknown): boolean {
|
||||||
|
if (typeof error !== "object" || error === null) return false;
|
||||||
|
const { code, responseCode, response } = error as { code?: unknown; responseCode?: unknown; response?: unknown };
|
||||||
|
if (code !== "EENVELOPE" || typeof responseCode !== "number") return false;
|
||||||
|
if (RECIPIENT_REJECTION_CODES.has(responseCode)) return true;
|
||||||
|
return responseCode >= 500 && responseCode < 600 && typeof response === "string" && /(^|\s)5\.1\.\d+(\s|$)/.test(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */
|
||||||
|
export function redactAddresses(text: string): string {
|
||||||
|
return text.replace(/[^\s<>"',;:()]+@[^\s<>"',;:()]+/g, "[Adresse]");
|
||||||
|
}
|
||||||
|
|
||||||
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
|
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
|
||||||
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
||||||
const pending = await db.emailMessage.findMany({
|
const pending = await db.emailMessage.findMany({
|
||||||
@@ -111,15 +133,17 @@ export async function flushEmails(db: Db, params: { bookingId?: string; limit?:
|
|||||||
sent++;
|
sent++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed++;
|
failed++;
|
||||||
const reason = error instanceof Error ? error.message.slice(0, 300) : "unbekannter Fehler";
|
const permanent = isPermanentMailError(error);
|
||||||
|
const reason = redactAddresses(error instanceof Error ? error.message : "unbekannter Fehler").slice(0, 300);
|
||||||
await db.emailMessage.update({
|
await db.emailMessage.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
data: {
|
data: {
|
||||||
lastError: reason,
|
lastError: reason,
|
||||||
status: message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
|
status: permanent || message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}): ${reason}`);
|
const final = permanent ? ", endgueltig abgelehnt" : "";
|
||||||
|
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}${final}): ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { sent, failed };
|
return { sent, failed };
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* URL eines Bildes ueber die Next.js-Bildoptimierung (AVIF/WebP, passende
|
||||||
|
* Breite) fuer Stellen, an denen kein <Image> moeglich ist, etwa das
|
||||||
|
* poster-Attribut von <video>. Ein 15-MB-Foto aus dem Medienverzeichnis wird
|
||||||
|
* so als wenige Dutzend Kilobyte ausgeliefert statt im Original.
|
||||||
|
*/
|
||||||
|
import { getImageProps } from "next/image";
|
||||||
|
|
||||||
|
/** Liefert eine Variante mit mindestens `width` Pixeln Breite. */
|
||||||
|
export function optimizedImageUrl(src: string, width: number): string {
|
||||||
|
// getImageProps gibt als src die 2x-Variante der angegebenen Breite
|
||||||
|
// zurueck; mit halber Breite entspricht sie der gewuenschten.
|
||||||
|
const half = Math.ceil(width / 2);
|
||||||
|
return getImageProps({ src, alt: "", width: half, height: half, quality: 75 }).props.src;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Folgeraten ueber Stripe Billing (docs/ARCHITEKTUR.md, ADR-3).
|
||||||
|
*
|
||||||
|
* Nach der bezahlten ersten Rate wird bei Stripe ein Subscription Schedule
|
||||||
|
* angelegt, der die Folgeraten zu ihren Faelligkeiten einzieht. Ab dann
|
||||||
|
* kuemmert sich Stripe um Einzug, Wiederholungen und Mahnungen; diese
|
||||||
|
* Anwendung verbucht nur die Rechnungen (Webhook, Abgleich im Worker).
|
||||||
|
*
|
||||||
|
* Voraussetzung, die payment-plan.ts garantiert: Alle Folgeraten sind gleich
|
||||||
|
* hoch (ein etwaiger Rest steckt in der ersten Rate). Der Zeitplan besteht
|
||||||
|
* deshalb aus einem einzigen Preis mit dem Ratenabstand in Tagen als
|
||||||
|
* Intervall und endet nach der letzten Rate von selbst.
|
||||||
|
*/
|
||||||
|
import { audit, WORKER, type Actor } from "./audit";
|
||||||
|
import type { Db } from "./db";
|
||||||
|
import { applyInstallmentInvoicePaid, flagBookingForReview, type EventContext } from "./payment-events";
|
||||||
|
import type { InvoiceInfo, PaymentGateway } from "./payment-gateway";
|
||||||
|
import { lockBooking } from "./booking-service";
|
||||||
|
import { CHARGE_HOUR_BERLIN } from "./payment-plan";
|
||||||
|
import { addDays, berlinDateString, berlinStartOfDay, daysBetween } from "./time";
|
||||||
|
|
||||||
|
export const INSTALLMENT_PLAN_KIND = "installment_plan";
|
||||||
|
/** Stripe verlangt einen Start in der Zukunft. */
|
||||||
|
const MIN_START_LEAD_MS = 60_000;
|
||||||
|
|
||||||
|
export type ScheduleOutcome = "created" | "exists" | "skipped" | "needs_review";
|
||||||
|
|
||||||
|
type Ctx = Pick<EventContext, "now" | "secret" | "adminEmail">;
|
||||||
|
|
||||||
|
export async function ensureInstallmentSchedule(
|
||||||
|
db: Db,
|
||||||
|
gateway: PaymentGateway,
|
||||||
|
bookingId: string,
|
||||||
|
ctx: Ctx,
|
||||||
|
actor: Actor,
|
||||||
|
): Promise<ScheduleOutcome> {
|
||||||
|
const booking = await db.booking.findUniqueOrThrow({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: { installments: { orderBy: { seq: "asc" } } },
|
||||||
|
});
|
||||||
|
if (booking.stripeScheduleId) return "exists";
|
||||||
|
if (booking.planType === "FULL" || booking.status !== "CONFIRMED") return "skipped";
|
||||||
|
if (booking.livemode !== (gateway.mode === "live")) return "skipped";
|
||||||
|
const later = booking.installments.filter((i) => i.seq > 1 && i.status === "PENDING");
|
||||||
|
if (later.length === 0) return "skipped";
|
||||||
|
|
||||||
|
const problem = schedulePlanProblem(booking, later, ctx.now);
|
||||||
|
if (problem) {
|
||||||
|
await db.$transaction((tx) => flagBookingForReview(tx, booking.id, problem, { ...ctx, actor }));
|
||||||
|
return "needs_review";
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = later[0]!;
|
||||||
|
const startAt = scheduleStart(first.dueAt!, ctx.now);
|
||||||
|
const intervalDays =
|
||||||
|
later.length === 1 ? 1 : daysBetween(berlinDateString(startAt), berlinDateString(later.at(-1)!.dueAt!));
|
||||||
|
if (intervalDays < 1) {
|
||||||
|
const reason = "Bis zur letzten Rate bleibt kein Abstand mehr – Ratenplan bei Stripe nicht angelegt.";
|
||||||
|
await db.$transaction((tx) => flagBookingForReview(tx, booking.id, reason, { ...ctx, actor }));
|
||||||
|
return "needs_review";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schutz gegen einen zweiten Plan, auch wenn das Speichern der ID frueher
|
||||||
|
// scheiterte und der Idempotenzschluessel (24 h) abgelaufen ist.
|
||||||
|
const existing = await gateway.findInstallmentSchedule(booking.stripeCustomerId!, booking.id);
|
||||||
|
// Nach einer Pruefung entsteht ein neuer Plan; ohne Generation lieferte
|
||||||
|
// Stripe zum alten Schluessel den bereits beendeten Plan zurueck.
|
||||||
|
const generation = await db.auditLog.count({
|
||||||
|
where: { entityType: "Booking", entityId: booking.id, action: "installments.schedule_held_for_review" },
|
||||||
|
});
|
||||||
|
const schedule =
|
||||||
|
existing ??
|
||||||
|
(await gateway.createInstallmentSchedule(
|
||||||
|
{
|
||||||
|
customerId: booking.stripeCustomerId!,
|
||||||
|
paymentMethodId: booking.stripePaymentMethodId!,
|
||||||
|
startAt,
|
||||||
|
amountCents: first.amountCents,
|
||||||
|
intervalDays,
|
||||||
|
count: later.length,
|
||||||
|
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Folgerate`,
|
||||||
|
metadata: { kind: INSTALLMENT_PLAN_KIND, bookingId: booking.id, reference: booking.reference },
|
||||||
|
},
|
||||||
|
// startAt ist fuer alle gleichzeitigen Aufrufer gleich (Faelligkeit
|
||||||
|
// oder naechster 9-Uhr-Termin), der Schluessel also auch.
|
||||||
|
`schedule:${booking.id}:${generation}:${startAt.getTime()}`,
|
||||||
|
));
|
||||||
|
|
||||||
|
const saved = await db.booking.updateMany({
|
||||||
|
where: { id: booking.id, stripeScheduleId: null },
|
||||||
|
data: { stripeScheduleId: schedule.id },
|
||||||
|
});
|
||||||
|
if (saved.count === 0) return "exists";
|
||||||
|
await audit(db, actor, "installments.schedule_created", "Booking", booking.id, {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
startAt: startAt.toISOString(),
|
||||||
|
intervalDays,
|
||||||
|
count: later.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wurde die Buchung waehrenddessen storniert, sofort wieder beenden.
|
||||||
|
const current = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, select: { status: true } });
|
||||||
|
if (current.status === "CANCELED") await gateway.cancelInstallmentSchedule(schedule.id);
|
||||||
|
return "created";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start des Plans: die Faelligkeit der naechsten Rate. Ist sie schon vorbei
|
||||||
|
* (Buchung erst nach Pruefung bestaetigt), der naechste 9-Uhr-Termin in
|
||||||
|
* Berlin — deterministisch, damit Wiederholungen denselben Plan meinen.
|
||||||
|
*/
|
||||||
|
function scheduleStart(dueAt: Date, now: Date): Date {
|
||||||
|
const earliest = now.getTime() + MIN_START_LEAD_MS;
|
||||||
|
if (dueAt.getTime() >= earliest) return dueAt;
|
||||||
|
const today = berlinDateString(now);
|
||||||
|
const todayAtNine = berlinStartOfDay(today, CHARGE_HOUR_BERLIN);
|
||||||
|
return todayAtNine.getTime() >= earliest ? todayAtNine : berlinStartOfDay(addDays(today, 1), CHARGE_HOUR_BERLIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haelt den Stripe-Plan an, solange eine Buchung in Pruefung ist (z. B.
|
||||||
|
* Rueckbuchung, Doppelzahlung): Plan beenden, offene Rechnungen stornieren,
|
||||||
|
* unbezahlte Folgeraten wieder auf PENDING. Bestaetigt ein Mensch die
|
||||||
|
* Buchung, legt createMissingSchedules einen neuen Plan fuer die offenen
|
||||||
|
* Raten an; storniert er, bleibt es dabei.
|
||||||
|
*/
|
||||||
|
export async function holdSchedulesInReview(db: Db, gateway: PaymentGateway, bookingIds?: string[], actor: Actor = WORKER) {
|
||||||
|
const candidates = await db.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: "NEEDS_REVIEW",
|
||||||
|
stripeScheduleId: { not: null },
|
||||||
|
livemode: gateway.mode === "live",
|
||||||
|
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true, stripeScheduleId: true },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
const failed = await eachBooking(candidates, "anhalten", async (booking) => {
|
||||||
|
// Erst bei Stripe beenden; scheitert das, bleibt alles wie es ist und der
|
||||||
|
// naechste Lauf versucht es erneut.
|
||||||
|
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await lockBooking(tx, booking.id);
|
||||||
|
await tx.installment.updateMany({
|
||||||
|
where: { bookingId: booking.id, seq: { gt: 1 }, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||||
|
data: { status: "PENDING", stripeInvoiceId: null, nextAttemptAt: null },
|
||||||
|
});
|
||||||
|
await tx.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: null } });
|
||||||
|
await audit(tx, actor, "installments.schedule_held_for_review", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { held: candidates.length - failed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbeitet Buchungen einzeln ab: ein Stripe-Fehler bei einer Buchung haelt
|
||||||
|
* weder die uebrigen noch den restlichen Worker-Lauf auf. Liefert die Zahl
|
||||||
|
* der Fehlschlaege.
|
||||||
|
*/
|
||||||
|
export async function eachBooking<T extends { id: string }>(items: T[], label: string, fn: (item: T) => Promise<void>): Promise<number> {
|
||||||
|
let failed = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
await fn(item);
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
console.error(`[ratenplan] ${label} fuer Buchung ${item.id} fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePlanProblem(
|
||||||
|
booking: { stripeCustomerId: string | null; stripePaymentMethodId: string | null; paymentMethodDetachedAt: Date | null },
|
||||||
|
later: { amountCents: number; dueAt: Date | null }[],
|
||||||
|
now: Date,
|
||||||
|
): string | null {
|
||||||
|
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
|
||||||
|
return "Keine gespeicherte Zahlungsmethode für die Folgeraten – Ratenplan bei Stripe nicht angelegt.";
|
||||||
|
}
|
||||||
|
if (later.some((i) => !i.dueAt) || later.some((i) => i.amountCents !== later[0]!.amountCents)) {
|
||||||
|
return "Folgeraten passen nicht zu einem Stripe-Ratenplan (Betrag oder Termin) – bitte prüfen.";
|
||||||
|
}
|
||||||
|
if (later.at(-1)!.dueAt! < now) {
|
||||||
|
return "Termin der letzten Rate ist bereits verstrichen – Ratenplan bei Stripe nicht angelegt.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet die Stripe-Ratenplaene stornierter Buchungen und trennt danach die
|
||||||
|
* gespeicherte Zahlungsmethode. paymentMethodDetachedAt markiert "erledigt";
|
||||||
|
* scheitert Stripe, bleibt die Buchung Kandidat fuer den naechsten Lauf.
|
||||||
|
*/
|
||||||
|
export async function stopCanceledInstallmentPlans(db: Db, gateway: PaymentGateway, bookingIds?: string[], actor: Actor = WORKER) {
|
||||||
|
const candidates = await db.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: "CANCELED",
|
||||||
|
stripeScheduleId: { not: null },
|
||||||
|
paymentMethodDetachedAt: null,
|
||||||
|
livemode: gateway.mode === "live",
|
||||||
|
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
||||||
|
},
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
const failed = await eachBooking(candidates, "beenden", async (booking) => {
|
||||||
|
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
|
||||||
|
if (booking.stripePaymentMethodId) await gateway.detachPaymentMethod(booking.stripePaymentMethodId);
|
||||||
|
await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } });
|
||||||
|
await audit(db, actor, "installments.schedule_canceled", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
|
||||||
|
});
|
||||||
|
return { stopped: candidates.length - failed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbucht eine bezahlte Rechnung des Zeitplans. Genutzt vom Webhook und vom
|
||||||
|
* Abgleich im Worker; mehrfacher Aufruf ist unschaedlich.
|
||||||
|
*/
|
||||||
|
export async function settleInstallmentInvoice(
|
||||||
|
db: Db,
|
||||||
|
gateway: PaymentGateway,
|
||||||
|
invoice: InvoiceInfo,
|
||||||
|
ctx: Ctx,
|
||||||
|
actor: Actor,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { kind, bookingId } = invoice.metadata;
|
||||||
|
if (kind !== INSTALLMENT_PLAN_KIND || !bookingId) return null;
|
||||||
|
if (invoice.status !== "paid" || !invoice.paymentIntentId) return null;
|
||||||
|
const pi = await gateway.retrievePaymentIntent(invoice.paymentIntentId);
|
||||||
|
await db.$transaction((tx) =>
|
||||||
|
applyInstallmentInvoicePaid(
|
||||||
|
tx,
|
||||||
|
bookingId,
|
||||||
|
invoice.id,
|
||||||
|
{
|
||||||
|
paymentIntentId: pi.id,
|
||||||
|
chargeId: pi.chargeId,
|
||||||
|
paymentMethodId: pi.paymentMethodId,
|
||||||
|
customerId: null,
|
||||||
|
amountCents: invoice.amountPaidCents,
|
||||||
|
},
|
||||||
|
{ ...ctx, actor },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return bookingId;
|
||||||
|
}
|
||||||
+65
-28
@@ -31,9 +31,6 @@ import {
|
|||||||
import { statusTokenFor } from "./tokens";
|
import { statusTokenFor } from "./tokens";
|
||||||
import { countUsedRedemptions } from "./vouchers";
|
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 EventContext = { now: Date; secret: string; actor: Actor; adminEmail: string | null };
|
||||||
|
|
||||||
export type PaymentInfo = {
|
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));
|
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";
|
export type FirstPaymentOutcome = "confirmed" | "duplicate" | "needs_review";
|
||||||
|
|
||||||
/** Erste Rate (bzw. Einmalzahlung) ist bei Stripe erfolgreich eingegangen. */
|
/** 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 };
|
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(
|
export async function applyInstallmentPaid(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
installmentId: string,
|
installmentId: string,
|
||||||
@@ -294,47 +296,82 @@ export async function applyInstallmentPaid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Off-Session-Einzug fehlgeschlagen. Greift nur, solange die Rate vom Worker
|
* Ordnet eine Rechnung des Stripe-Zeitplans ihrer Folgerate zu. Stripe
|
||||||
* beansprucht ist (PROCESSING) — so zaehlt ein Fehler nie doppelt, egal ob
|
* erzeugt die Rechnungen nacheinander (die naechste erst im naechsten
|
||||||
* ihn der Worker direkt oder der Webhook zuerst meldet.
|
* 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,
|
tx: Tx,
|
||||||
installmentId: string,
|
bookingId: string,
|
||||||
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
|
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,
|
ctx: EventContext,
|
||||||
): Promise<"recorded" | "ignored"> {
|
): Promise<"recorded" | "ignored"> {
|
||||||
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
|
const booking = await loadLocked(tx, bookingId);
|
||||||
const booking = await loadLocked(tx, target.bookingId);
|
const installment = await installmentForInvoice(tx, booking, invoice.id);
|
||||||
const installment = booking.installments.find((i) => i.id === installmentId)!;
|
if (!installment || installment.status === "PAID" || installment.status === "CANCELED") return "ignored";
|
||||||
if (installment.status !== "PROCESSING") 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({
|
const updatedInstallment = await tx.installment.update({
|
||||||
where: { id: installmentId },
|
where: { id: installment.id },
|
||||||
data: {
|
data: {
|
||||||
status: info.requiresAction ? "REQUIRES_ACTION" : "FAILED",
|
status,
|
||||||
attemptCount: attempt,
|
attemptCount: Math.max(invoice.attemptCount, installment.attemptCount),
|
||||||
failureCode: info.failureCode,
|
lastAttemptAt: ctx.now,
|
||||||
stripePaymentIntentId: info.paymentIntentId ?? installment.stripePaymentIntentId,
|
failureCode: info.requiresAction ? "authentication_required" : "payment_failed",
|
||||||
nextAttemptAt: retry ? new Date(ctx.now.getTime() + RETRY_DELAY_DAYS * 86_400_000) : null,
|
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,
|
bookingId: booking.id,
|
||||||
seq: installment.seq,
|
seq: installment.seq,
|
||||||
attempt,
|
invoiceId: invoice.id,
|
||||||
failureCode: info.failureCode,
|
attempt: invoice.attemptCount,
|
||||||
requiresAction: info.requiresAction,
|
requiresAction: info.requiresAction,
|
||||||
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
|
nextAttemptAt: invoice.nextPaymentAttempt?.toISOString() ?? null,
|
||||||
});
|
});
|
||||||
await enqueueEmail(
|
await enqueueEmail(
|
||||||
tx,
|
tx,
|
||||||
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
|
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
|
||||||
);
|
);
|
||||||
if (!retry && !info.requiresAction) {
|
if (!info.requiresAction && !invoice.nextPaymentAttempt) {
|
||||||
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${attempt} Versuchen nicht eingegangen.`, ctx);
|
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${invoice.attemptCount} Versuchen nicht eingegangen.`, ctx);
|
||||||
}
|
}
|
||||||
return "recorded";
|
return "recorded";
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-53
@@ -7,12 +7,12 @@
|
|||||||
* Checkout erfasst sie, hier liegen nur Objekt-IDs.
|
* Checkout erfasst sie, hier liegen nur Objekt-IDs.
|
||||||
*
|
*
|
||||||
* Architekturentscheidung Ratenzahlung (docs/ARCHITEKTUR.md, ADR-3):
|
* Architekturentscheidung Ratenzahlung (docs/ARCHITEKTUR.md, ADR-3):
|
||||||
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session,
|
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session.
|
||||||
* Folgeraten als einzelne Off-Session-PaymentIntents, ausgeloest durch einen
|
* Die Folgeraten zieht Stripe Billing ein: Nach der ersten Zahlung legt die
|
||||||
* idempotenten Kubernetes-CronJob. Kein Abonnement, kein Subscription
|
* Anwendung einen Subscription Schedule an (Start = Faelligkeit der zweiten
|
||||||
* Schedule: deren Intervalle passen nicht zur Regel "letzte Rate 30 Tage vor
|
* Rate, Intervall = Abstand der Raten in Tagen, end_behavior=cancel). Stripe
|
||||||
* Anreise", und die Planlaenge ist hier durch die Ratenzeilen selbst
|
* uebernimmt Einzug, Wiederholungen (Smart Retries), Bestaetigungslinks bei
|
||||||
* begrenzt.
|
* 3-D Secure und Mahnungen; die Anwendung verbucht nur die Rechnungs-Events.
|
||||||
*/
|
*/
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { env, stripeMode } from "./env";
|
import { env, stripeMode } from "./env";
|
||||||
@@ -37,11 +37,32 @@ export type PaymentIntentInfo = {
|
|||||||
metadata: Record<string, string>;
|
metadata: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OffSessionResult = {
|
export type InvoiceInfo = {
|
||||||
outcome: "succeeded" | "processing" | "requires_action" | "failed";
|
id: string;
|
||||||
|
status: "draft" | "open" | "paid" | "uncollectible" | "void";
|
||||||
|
amountPaidCents: number;
|
||||||
paymentIntentId: string | null;
|
paymentIntentId: string | null;
|
||||||
chargeId: string | null;
|
hostedInvoiceUrl: string | null;
|
||||||
failureCode: string | null;
|
attemptCount: number;
|
||||||
|
/** null = Stripe versucht keinen weiteren automatischen Einzug. */
|
||||||
|
nextPaymentAttempt: Date | null;
|
||||||
|
/** Metadaten des Abonnements zum Rechnungszeitpunkt (bookingId usw.). */
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateInstallmentScheduleParams = {
|
||||||
|
customerId: string;
|
||||||
|
paymentMethodId: string;
|
||||||
|
/** Faelligkeit der ersten Folgerate. */
|
||||||
|
startAt: Date;
|
||||||
|
/** Betrag je Folgerate (alle Folgeraten sind gleich hoch). */
|
||||||
|
amountCents: number;
|
||||||
|
/** Abstand zwischen den Folgeraten in Tagen. */
|
||||||
|
intervalDays: number;
|
||||||
|
/** Anzahl der Folgeraten. */
|
||||||
|
count: number;
|
||||||
|
description: string;
|
||||||
|
metadata: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateCheckoutParams = {
|
export type CreateCheckoutParams = {
|
||||||
@@ -65,10 +86,14 @@ export interface PaymentGateway {
|
|||||||
retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo>;
|
retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo>;
|
||||||
expireCheckoutSession(id: string): Promise<void>;
|
expireCheckoutSession(id: string): Promise<void>;
|
||||||
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
|
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
|
||||||
chargeOffSession(
|
createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string): Promise<{ id: string }>;
|
||||||
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
|
/** Laufender oder noch nicht gestarteter Plan dieser Buchung bei Stripe, falls vorhanden. */
|
||||||
idempotencyKey: string,
|
findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null>;
|
||||||
): Promise<OffSessionResult>;
|
/** Beendet den Zeitplan sofort und storniert offene Rechnungen: danach wird nichts mehr eingezogen. */
|
||||||
|
cancelInstallmentSchedule(id: string): Promise<void>;
|
||||||
|
retrieveInvoice(id: string): Promise<InvoiceInfo>;
|
||||||
|
/** Alle Rechnungen, die der Zeitplan bisher erzeugt hat (fuer den Abgleich). */
|
||||||
|
listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]>;
|
||||||
detachPaymentMethod(id: string): Promise<void>;
|
detachPaymentMethod(id: string): Promise<void>;
|
||||||
createRefund(
|
createRefund(
|
||||||
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
|
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
|
||||||
@@ -174,48 +199,97 @@ export class StripeGateway implements PaymentGateway {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async chargeOffSession(
|
async createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string) {
|
||||||
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
|
const productId = await this.installmentProductId();
|
||||||
idempotencyKey: string,
|
const start = Math.floor(params.startAt.getTime() / 1000);
|
||||||
): Promise<OffSessionResult> {
|
const schedule = await this.stripe.subscriptionSchedules.create(
|
||||||
try {
|
{
|
||||||
const pi = await this.stripe.paymentIntents.create(
|
customer: params.customerId,
|
||||||
{
|
start_date: start,
|
||||||
amount: params.amountCents,
|
// Endet genau mit dem Abrechnungszeitraum der letzten Rate: Stripe
|
||||||
currency: "eur",
|
// kuendigt dann ohne weitere Rechnung (mit Testuhr geprueft).
|
||||||
customer: params.customerId,
|
end_behavior: "cancel",
|
||||||
payment_method: params.paymentMethodId,
|
default_settings: {
|
||||||
off_session: true,
|
collection_method: "charge_automatically",
|
||||||
confirm: true,
|
default_payment_method: params.paymentMethodId,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
metadata: params.metadata,
|
|
||||||
},
|
},
|
||||||
{ idempotencyKey },
|
phases: [
|
||||||
);
|
{
|
||||||
return {
|
end_date: start + params.count * params.intervalDays * DAY_SECONDS,
|
||||||
outcome:
|
proration_behavior: "none",
|
||||||
pi.status === "succeeded"
|
items: [
|
||||||
? "succeeded"
|
{
|
||||||
: pi.status === "processing"
|
price_data: {
|
||||||
? "processing"
|
currency: "eur",
|
||||||
: pi.status === "requires_action"
|
product: productId,
|
||||||
? "requires_action"
|
unit_amount: params.amountCents,
|
||||||
: "failed",
|
recurring: { interval: "day", interval_count: params.intervalDays },
|
||||||
paymentIntentId: pi.id,
|
},
|
||||||
chargeId: idOf(pi.latest_charge),
|
},
|
||||||
failureCode: pi.last_payment_error?.code ?? null,
|
],
|
||||||
};
|
metadata: params.metadata,
|
||||||
} catch (error) {
|
},
|
||||||
if (error instanceof Stripe.errors.StripeCardError) {
|
],
|
||||||
return {
|
metadata: params.metadata,
|
||||||
outcome: error.code === "authentication_required" ? "requires_action" : "failed",
|
},
|
||||||
paymentIntentId: error.payment_intent?.id ?? null,
|
{ idempotencyKey },
|
||||||
chargeId: null,
|
);
|
||||||
failureCode: error.decline_code ?? error.code ?? "card_error",
|
return { id: schedule.id };
|
||||||
};
|
}
|
||||||
}
|
|
||||||
throw error;
|
async findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null> {
|
||||||
|
for await (const schedule of this.stripe.subscriptionSchedules.list({ customer: customerId, limit: 100 })) {
|
||||||
|
const live = schedule.status === "not_started" || schedule.status === "active";
|
||||||
|
if (live && schedule.metadata?.bookingId === bookingId) return { id: schedule.id };
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feste Produkt-ID: beim ersten Bedarf angelegt, danach wiederverwendet. */
|
||||||
|
private async installmentProductId(): Promise<string> {
|
||||||
|
try {
|
||||||
|
return (await this.stripe.products.retrieve(INSTALLMENT_PRODUCT_ID)).id;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Stripe.errors.StripeInvalidRequestError)) throw error;
|
||||||
|
const product = await this.stripe.products.create(
|
||||||
|
{ id: INSTALLMENT_PRODUCT_ID, name: "ARTISTA MODELCAMP 2027, Folgerate" },
|
||||||
|
{ idempotencyKey: `product:${INSTALLMENT_PRODUCT_ID}` },
|
||||||
|
);
|
||||||
|
return product.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelInstallmentSchedule(id: string): Promise<void> {
|
||||||
|
const schedule = await this.stripe.subscriptionSchedules.retrieve(id);
|
||||||
|
if (schedule.status === "not_started" || schedule.status === "active") {
|
||||||
|
await this.stripe.subscriptionSchedules.cancel(id, { invoice_now: false, prorate: false });
|
||||||
|
}
|
||||||
|
const subscriptionId = idOf(schedule.subscription);
|
||||||
|
if (!subscriptionId) return;
|
||||||
|
// Offene Rechnungen wuerden sonst weiter automatisch eingezogen. Entwuerfe
|
||||||
|
// (Stripe stellt Rechnungen erst ~1 h nach Periodenbeginn fertig) bleiben
|
||||||
|
// laut Testuhr nach dem Beenden zwar liegen, werden aber vorsorglich
|
||||||
|
// geloescht, damit sie nie fertiggestellt und eingezogen werden.
|
||||||
|
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, limit: 100 })) {
|
||||||
|
if (invoice.status === "draft") await this.stripe.invoices.del(invoice.id!);
|
||||||
|
else if (invoice.status === "open") await this.stripe.invoices.voidInvoice(invoice.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveInvoice(id: string): Promise<InvoiceInfo> {
|
||||||
|
return invoiceInfoFromStripe(await this.stripe.invoices.retrieve(id, { expand: ["payments"] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]> {
|
||||||
|
const schedule = await this.stripe.subscriptionSchedules.retrieve(scheduleId);
|
||||||
|
const subscriptionId = idOf(schedule.subscription);
|
||||||
|
if (!subscriptionId) return [];
|
||||||
|
const invoices: InvoiceInfo[] = [];
|
||||||
|
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, limit: 100, expand: ["data.payments"] })) {
|
||||||
|
invoices.push(invoiceInfoFromStripe(invoice));
|
||||||
|
}
|
||||||
|
return invoices;
|
||||||
}
|
}
|
||||||
|
|
||||||
async detachPaymentMethod(id: string): Promise<void> {
|
async detachPaymentMethod(id: string): Promise<void> {
|
||||||
@@ -239,6 +313,29 @@ export class StripeGateway implements PaymentGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DAY_SECONDS = 86_400;
|
||||||
|
const INSTALLMENT_PRODUCT_ID = "artista_modelcamp_2027_folgerate";
|
||||||
|
|
||||||
|
/** Seit API 2025-03-31 haengt die Zahlung einer Rechnung an invoice.payments. */
|
||||||
|
function paidPaymentIntentId(invoice: Stripe.Invoice): string | null {
|
||||||
|
const payments = invoice.payments?.data ?? [];
|
||||||
|
const paid = payments.find((p) => p.status === "paid") ?? payments[0];
|
||||||
|
return idOf(paid?.payment.payment_intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invoiceInfoFromStripe(invoice: Stripe.Invoice): InvoiceInfo {
|
||||||
|
return {
|
||||||
|
id: invoice.id!,
|
||||||
|
status: (invoice.status ?? "draft") as InvoiceInfo["status"],
|
||||||
|
amountPaidCents: invoice.amount_paid,
|
||||||
|
paymentIntentId: paidPaymentIntentId(invoice),
|
||||||
|
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
|
||||||
|
attemptCount: invoice.attempt_count,
|
||||||
|
nextPaymentAttempt: invoice.next_payment_attempt ? new Date(invoice.next_payment_attempt * 1000) : null,
|
||||||
|
metadata: (invoice.parent?.subscription_details?.metadata ?? {}) as Record<string, string>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let gateway: PaymentGateway | null = null;
|
let gateway: PaymentGateway | null = null;
|
||||||
|
|
||||||
export function getGateway(): PaymentGateway {
|
export function getGateway(): PaymentGateway {
|
||||||
|
|||||||
+105
-7
@@ -5,6 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
||||||
*/
|
*/
|
||||||
|
import { BlockList, isIP } from "node:net";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
import { hmacHex } from "./tokens";
|
import { hmacHex } from "./tokens";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export const RATE_LIMITS = {
|
|||||||
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
|
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
|
||||||
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
|
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
|
||||||
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
|
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
|
||||||
|
contact: { action: "contact", limit: 5, windowSeconds: 3600 },
|
||||||
} satisfies Record<string, RateLimitRule>;
|
} satisfies Record<string, RateLimitRule>;
|
||||||
|
|
||||||
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
|
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
|
||||||
@@ -50,14 +52,110 @@ export async function purgeRateLimits(db: Db): Promise<number> {
|
|||||||
return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`;
|
return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vom Server gesetzte Client-IP fuer better-auth (withClientIp), nie vom Client uebernommen. */
|
||||||
|
export const CLIENT_IP_HEADER = "x-artista-client-ip";
|
||||||
|
|
||||||
|
/** https://www.cloudflare.com/ips/ (Stand 2026-10). */
|
||||||
|
const CLOUDFLARE: [string, number, "ipv4" | "ipv6"][] = [
|
||||||
|
...["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22"].map(
|
||||||
|
(cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4"] as [string, number, "ipv4"],
|
||||||
|
),
|
||||||
|
...["2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"].map(
|
||||||
|
(cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv6"] as [string, number, "ipv6"],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const cloudflare = new BlockList();
|
||||||
|
for (const [net, prefix, family] of CLOUDFLARE) cloudflare.addSubnet(net, prefix, family);
|
||||||
|
|
||||||
|
/** Nicht oeffentliche Bereiche; 240/4 nutzt Cloudflare fuer "Pseudo IPv4". */
|
||||||
|
const NON_PUBLIC_V4 = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "240.0.0.0/4"];
|
||||||
|
const nonPublic = new BlockList();
|
||||||
|
for (const cidr of NON_PUBLIC_V4) nonPublic.addSubnet(cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4");
|
||||||
|
nonPublic.addAddress("255.255.255.255", "ipv4");
|
||||||
|
nonPublic.addAddress("::", "ipv6");
|
||||||
|
nonPublic.addAddress("::1", "ipv6");
|
||||||
|
nonPublic.addSubnet("fc00::", 7, "ipv6");
|
||||||
|
nonPublic.addSubnet("fe80::", 10, "ipv6");
|
||||||
|
|
||||||
|
function isPublicUnicast(ip: string): boolean {
|
||||||
|
if (isIP(ip) === 4) return !nonPublic.check(ip, "ipv4");
|
||||||
|
const groups = ipv6Groups(ip);
|
||||||
|
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||||
|
const [high = 0, low = 0] = groups.slice(6);
|
||||||
|
return !nonPublic.check([high >> 8, high & 0xff, low >> 8, low & 0xff].join("."), "ipv4");
|
||||||
|
}
|
||||||
|
return !nonPublic.check(ip, "ipv6");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validIp(value: string | null | undefined): string | null {
|
||||||
|
const ip = value?.trim();
|
||||||
|
return ip && isIP(ip) ? ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-IP hinter Traefik. Traefik setzt X-Real-Ip selbst und ersetzt
|
* Client-IP hinter Traefik. Gegenstelle ist der letzte Eintrag in
|
||||||
* X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint-
|
* X-Forwarded-For, den Traefik aus der TCP-Verbindung anhaengt; alles links
|
||||||
* Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md).
|
* davon und X-Real-IP kann vom Client stammen, sobald die Anfrage ueber
|
||||||
|
* Cloudflare kommt (Traefik vertraut Cloudflare, forwardedHeaders.trustedIPs).
|
||||||
|
* Ist die Gegenstelle eine Cloudflare-Edge, gilt CF-Connecting-IP. Ohne
|
||||||
|
* X-Forwarded-For (kein Traefik davor, lokal) gilt X-Real-IP.
|
||||||
*/
|
*/
|
||||||
export function clientIp(headers: Headers): string {
|
export function clientIp(headers: Headers): string {
|
||||||
const realIp = headers.get("x-real-ip")?.trim();
|
const forwarded = headers.get("x-forwarded-for");
|
||||||
if (realIp) return realIp;
|
const realIp = process.env.NODE_ENV === "production" ? null : validIp(headers.get("x-real-ip"));
|
||||||
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
const peer = forwarded === null ? realIp : validIp(forwarded.split(",").at(-1));
|
||||||
return forwarded || "unknown";
|
if (!peer) return "unknown";
|
||||||
|
const viaCloudflare = validIp(headers.get("cf-connecting-ip"));
|
||||||
|
return viaCloudflare && isPublicUnicast(viaCloudflare) && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die acht 16-Bit-Gruppen einer gueltigen IPv6-Adresse; eine IPv4-Endung (::ffff:a.b.c.d) wird umgerechnet. */
|
||||||
|
function ipv6Groups(ip: string): number[] {
|
||||||
|
let text = ip.toLowerCase().split("%")[0] ?? "";
|
||||||
|
const dotted = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
|
||||||
|
if (dotted) {
|
||||||
|
const [a = 0, b = 0, c = 0, d = 0] = dotted.slice(1).map(Number);
|
||||||
|
text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
||||||
|
}
|
||||||
|
const parse = (part: string) => (part ? part.split(":").map((group) => Number.parseInt(group, 16)) : []);
|
||||||
|
const [head = "", tail] = text.split("::");
|
||||||
|
const left = parse(head);
|
||||||
|
if (tail === undefined) return left;
|
||||||
|
const right = parse(tail);
|
||||||
|
return [...left, ...new Array<number>(8 - left.length - right.length).fill(0), ...right];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel fuer consumeRateLimit: IPv4 unveraendert, IPv6 als /64-Netz. Ein
|
||||||
|
* Anschluss bekommt meist ein ganzes /64; einzeln gezaehlt liesse sich jedes
|
||||||
|
* Limit durch Wechsel der Adresse innerhalb des eigenen Netzes umgehen.
|
||||||
|
*/
|
||||||
|
export function rateLimitSubject(ip: string): string {
|
||||||
|
if (isIP(ip) !== 6) return ip;
|
||||||
|
const groups = ipv6Groups(ip);
|
||||||
|
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||||
|
const [high = 0, low = 0] = groups.slice(6);
|
||||||
|
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||||
|
}
|
||||||
|
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(":")}::/64`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopie der Header mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
|
||||||
|
export function headersWithClientIp(source: Headers): Headers {
|
||||||
|
const headers = new Headers(source);
|
||||||
|
headers.set(CLIENT_IP_HEADER, clientIp(source));
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt.
|
||||||
|
*
|
||||||
|
* Bewusst aus URL, Methode und Body neu gebaut statt `new Request(request, …)`:
|
||||||
|
* Next reicht Route-Handlern einen Proxy um NextRequest, und undici 7 (Node 24)
|
||||||
|
* prueft private Felder (#state), die ein Proxy nicht hat ("Cannot read private
|
||||||
|
* member #state"). Unter Node 22 (undici 6, Symbole) fiel das nicht auf.
|
||||||
|
*/
|
||||||
|
export async function withClientIp(request: Request): Promise<Request> {
|
||||||
|
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
|
||||||
|
return new Request(request.url, { method: request.method, headers: headersWithClientIp(request.headers), body, signal: request.signal });
|
||||||
}
|
}
|
||||||
@@ -107,8 +107,11 @@ export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
|
|||||||
export function evaluateSales(now: Date, camp: CampGateFields, mode: StripeMode): SalesState {
|
export function evaluateSales(now: Date, camp: CampGateFields, mode: StripeMode): SalesState {
|
||||||
const phase = pricePhaseAt(now);
|
const phase = pricePhaseAt(now);
|
||||||
const simulate = mode === "test" && camp.testPhaseOverride !== null && camp.testPhaseOverride !== "PREVIEW";
|
const simulate = mode === "test" && camp.testPhaseOverride !== null && camp.testPhaseOverride !== "PREVIEW";
|
||||||
const effectivePhase = simulate ? camp.testPhaseOverride! : phase;
|
|
||||||
const blockers = releaseBlockers(camp);
|
const blockers = releaseBlockers(camp);
|
||||||
|
// Sind alle Freigaben bestaetigt, startet der Verkauf sofort, auch vor dem
|
||||||
|
// 01.11.2026: Bis dahin gilt der Early-Bird-Preis.
|
||||||
|
const releasedEarly = phase === "PREVIEW" && blockers.length === 0;
|
||||||
|
const effectivePhase = simulate ? camp.testPhaseOverride! : releasedEarly ? "EARLY_BIRD" : phase;
|
||||||
const campStarted = camp.campStartDate !== null && now.getTime() >= berlinStartOfDay(camp.campStartDate).getTime();
|
const campStarted = camp.campStartDate !== null && now.getTime() >= berlinStartOfDay(camp.campStartDate).getTime();
|
||||||
|
|
||||||
let closedReason: SalesState["closedReason"] = null;
|
let closedReason: SalesState["closedReason"] = null;
|
||||||
|
|||||||
+102
-16
@@ -10,60 +10,144 @@
|
|||||||
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
|
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
|
||||||
* sofort sichtbar, obwohl /media/… einen Tag lang gecacht wird.
|
* sofort sichtbar, obwohl /media/… einen Tag lang gecacht wird.
|
||||||
*/
|
*/
|
||||||
import { stat } from "node:fs/promises";
|
import { readdir, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { site, type Media } from "@/content/site";
|
import { site, type Media } from "@/content/site";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { optimizedImageUrl } from "./image-url";
|
||||||
|
|
||||||
const IMAGE_EXTS = [".jpg", ".jpeg", ".webp", ".png", ".avif"];
|
const IMAGE_EXTS = [".jpg", ".jpeg", ".webp", ".png", ".avif"];
|
||||||
const LOGO_EXTS = [".svg", ".png", ".webp"];
|
const LOGO_EXTS = [".svg", ".png", ".webp"];
|
||||||
const VIDEO_EXTS = [".mp4", ".webm"];
|
const VIDEO_EXTS = [".mp4", ".webm"];
|
||||||
|
|
||||||
type Found = { src: string; ext: string };
|
type Found = { src: string; ext: string; file: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht "<ordner>/<name>.<endung>" ohne Ruecksicht auf Gross- und
|
||||||
|
* Kleinschreibung: Im Container (Linux) waere "Reel-1.JPG" sonst eine
|
||||||
|
* andere Datei als "reel-1.jpg". Geliefert wird der echte Dateiname.
|
||||||
|
*/
|
||||||
async function find(base: string, exts: readonly string[]): Promise<Found | null> {
|
async function find(base: string, exts: readonly string[]): Promise<Found | null> {
|
||||||
const dir = path.resolve(env().MEDIA_DIR);
|
const root = path.resolve(env().MEDIA_DIR);
|
||||||
|
const folder = path.posix.dirname(base);
|
||||||
|
const name = path.posix.basename(base).toLowerCase();
|
||||||
|
const dir = folder === "." ? root : path.join(root, folder);
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir);
|
||||||
|
} catch {
|
||||||
|
return null; // Ordner fehlt: Platzhalter bleibt.
|
||||||
|
}
|
||||||
for (const ext of exts) {
|
for (const ext of exts) {
|
||||||
|
const match = entries.find((entry) => entry.toLowerCase() === `${name}${ext}`);
|
||||||
|
if (!match) continue;
|
||||||
try {
|
try {
|
||||||
const info = await stat(path.join(dir, `${base}${ext}`));
|
const info = await stat(path.join(dir, match));
|
||||||
if (info.isFile()) return { src: `/media/${base}${ext}?v=${Math.floor(info.mtimeMs)}`, ext };
|
if (!info.isFile()) continue;
|
||||||
|
const rel = folder === "." ? match : `${folder}/${match}`;
|
||||||
|
return { src: `/media/${encodeURI(rel)}?v=${Math.floor(info.mtimeMs)}`, ext, file: path.join(dir, match) };
|
||||||
} catch {
|
} catch {
|
||||||
// Datei fehlt: naechste Endung probieren.
|
// Datei zwischenzeitlich entfernt: naechste Endung probieren.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video mit Vorrang fuer die Web-Fassung "<name>.web.mp4" (1080p,
|
||||||
|
* faststart, siehe scripts/optimize-media.ts). Fehlt sie, das Original.
|
||||||
|
*/
|
||||||
|
async function video(base: string): Promise<Found | null> {
|
||||||
|
return (await find(`${base}.web`, VIDEO_EXTS)) ?? find(base, VIDEO_EXTS);
|
||||||
|
}
|
||||||
|
|
||||||
async function image(base: string, alt: string): Promise<Media | null> {
|
async function image(base: string, alt: string): Promise<Media | null> {
|
||||||
const found = await find(base, IMAGE_EXTS);
|
const found = await find(base, IMAGE_EXTS);
|
||||||
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
|
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logo samt Darstellung: "darken" bzw. "lighten" faerbt ein Logo per CSS um,
|
||||||
|
* wenn fuer den Untergrund keine passende Fassung hochgeladen wurde (z. B.
|
||||||
|
* nur ein helles Logo, das auf der hellen Kopfzeile sonst unsichtbar waere).
|
||||||
|
*/
|
||||||
|
export type LogoImage = { src: string; tone: "as-is" | "darken" | "lighten" };
|
||||||
|
|
||||||
|
type Tone = "light" | "dark";
|
||||||
|
/** Mittlere Helligkeit (0–255) der sichtbaren Pixel, ab der ein Logo als hell gilt. */
|
||||||
|
const LIGHT_LOGO_LUMINANCE = 140;
|
||||||
|
/** Breite der ausgelieferten Rastergrafik: scharf bis ~400 px Anzeigebreite auf 2x-Displays. */
|
||||||
|
const LOGO_DELIVERY_WIDTH = 828;
|
||||||
|
const toneCache = new Map<string, Tone>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hell oder dunkel? Gemessen statt am Dateinamen festgemacht: Wird das helle
|
||||||
|
* Logo als logo.png hochgeladen, landet es trotzdem auf dem dunklen Grund.
|
||||||
|
*/
|
||||||
|
async function logoTone(found: Found): Promise<Tone> {
|
||||||
|
const cached = toneCache.get(found.src);
|
||||||
|
if (cached) return cached;
|
||||||
|
const { data } = await sharp(found.file).resize(64, 64, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||||
|
let luminance = 0;
|
||||||
|
let weight = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const alpha = data[i + 3]! / 255;
|
||||||
|
if (alpha < 0.2) continue;
|
||||||
|
luminance += (0.2126 * data[i]! + 0.7152 * data[i + 1]! + 0.0722 * data[i + 2]!) * alpha;
|
||||||
|
weight += alpha;
|
||||||
|
}
|
||||||
|
const tone: Tone = weight > 0 && luminance / weight >= LIGHT_LOGO_LUMINANCE ? "light" : "dark";
|
||||||
|
toneCache.set(found.src, tone);
|
||||||
|
return tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliver(found: Found): string {
|
||||||
|
// SVG direkt (laeuft nicht durch die Bildoptimierung), Raster verkleinert:
|
||||||
|
// das Original-PNG hatte 5378 px Breite.
|
||||||
|
return found.ext === ".svg" ? found.src : optimizedImageUrl(found.src, LOGO_DELIVERY_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLogos(candidates: (Found | null)[]): Promise<{ onLight: LogoImage | null; onDark: LogoImage | null }> {
|
||||||
|
const found = candidates.filter((c): c is Found => c !== null);
|
||||||
|
const toned = await Promise.all(found.map(async (f) => ({ f, tone: await logoTone(f).catch((): Tone => "dark") })));
|
||||||
|
const dark = toned.find((t) => t.tone === "dark")?.f;
|
||||||
|
const light = toned.find((t) => t.tone === "light")?.f;
|
||||||
|
const onLight: LogoImage | null = dark ? { src: deliver(dark), tone: "as-is" } : light ? { src: deliver(light), tone: "darken" } : null;
|
||||||
|
const onDark: LogoImage | null = light ? { src: deliver(light), tone: "as-is" } : dark ? { src: deliver(dark), tone: "lighten" } : null;
|
||||||
|
return { onLight, onDark };
|
||||||
|
}
|
||||||
|
|
||||||
export type Reel = { src: string | null; poster: Media | null };
|
export type Reel = { src: string | null; poster: Media | null };
|
||||||
|
export type IntroVideo = { src: string | null; poster: Media | null };
|
||||||
|
|
||||||
export type SiteMedia = {
|
export type SiteMedia = {
|
||||||
/** Logo fuer helle Flaechen (dunkles Logo). */
|
/** Logo fuer helle Flaechen (Kopfzeile nach dem Scrollen, Fussbereich). */
|
||||||
logoDark: string | null;
|
logoDark: LogoImage | null;
|
||||||
/** Logo auf Foto oder dunklem Grund (helles Logo). */
|
/** Logo auf Foto oder dunklem Grund. */
|
||||||
logoLight: string | null;
|
logoLight: LogoImage | null;
|
||||||
header: Media;
|
header: Media;
|
||||||
headerVideo: string | null;
|
headerVideo: string | null;
|
||||||
|
/** Video direkt unter dem Headerbild (intro.mp4, Vorschaubild intro.jpg). */
|
||||||
|
intro: IntroVideo;
|
||||||
portraits: Record<string, Media>;
|
portraits: Record<string, Media>;
|
||||||
reels: Reel[];
|
reels: Reel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadSiteMedia(): Promise<SiteMedia> {
|
export async function loadSiteMedia(): Promise<SiteMedia> {
|
||||||
const people = [site.team.head, ...site.team.people];
|
const people = [site.team.head, ...site.team.people];
|
||||||
const [logoDark, logoLight, header, headerVideo, portraitList, reels] = await Promise.all([
|
const [logoDark, logoLight, header, headerVideo, introVideo, introPoster, portraitList, reels] = await Promise.all([
|
||||||
find("logo", LOGO_EXTS),
|
find("logo", LOGO_EXTS),
|
||||||
find("logo-hell", LOGO_EXTS),
|
find("logo-hell", LOGO_EXTS),
|
||||||
image("header", "ARTISTA Modelcamp in der Toskana"),
|
image("header", "ARTISTA Modelcamp in der Toskana"),
|
||||||
find("header", VIDEO_EXTS),
|
video("header"),
|
||||||
|
video("intro"),
|
||||||
|
image("intro", "Vorschaubild Video ARTISTA Modelcamp"),
|
||||||
Promise.all(people.map((p) => image(`team/${p.slug}`, `Porträt ${p.name}`))),
|
Promise.all(people.map((p) => image(`team/${p.slug}`, `Porträt ${p.name}`))),
|
||||||
Promise.all(
|
Promise.all(
|
||||||
[1, 2, 3].map(async (n): Promise<Reel> => {
|
[1, 2, 3].map(async (n): Promise<Reel> => {
|
||||||
const [video, poster] = await Promise.all([find(`reels/reel-${n}`, VIDEO_EXTS), image(`reels/reel-${n}`, `Vorschaubild Reel ${n}`)]);
|
const [clip, poster] = await Promise.all([video(`reels/reel-${n}`), image(`reels/reel-${n}`, `Vorschaubild Reel ${n}`)]);
|
||||||
return { src: video?.src ?? null, poster };
|
return { src: clip?.src ?? null, poster };
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -73,11 +157,13 @@ export async function loadSiteMedia(): Promise<SiteMedia> {
|
|||||||
portraits[p.slug] = portraitList[index] ?? p.portrait;
|
portraits[p.slug] = portraitList[index] ?? p.portrait;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const logos = await resolveLogos([logoDark, logoLight]);
|
||||||
return {
|
return {
|
||||||
logoDark: logoDark?.src ?? logoLight?.src ?? null,
|
logoDark: logos.onLight,
|
||||||
logoLight: logoLight?.src ?? logoDark?.src ?? null,
|
logoLight: logos.onDark,
|
||||||
header: header ?? site.hero.media,
|
header: header ?? site.hero.media,
|
||||||
headerVideo: headerVideo?.src ?? null,
|
headerVideo: headerVideo?.src ?? null,
|
||||||
|
intro: { src: introVideo?.src ?? null, poster: introPoster },
|
||||||
portraits,
|
portraits,
|
||||||
reels,
|
reels,
|
||||||
};
|
};
|
||||||
|
|||||||
+76
-91
@@ -13,6 +13,7 @@ import type Stripe from "stripe";
|
|||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { STRIPE_ACTOR, type Actor } from "./audit";
|
import { STRIPE_ACTOR, type Actor } from "./audit";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
|
import { ensureInstallmentSchedule, holdSchedulesInReview, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing";
|
||||||
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
|
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
|
||||||
import {
|
import {
|
||||||
applyCheckoutExpired,
|
applyCheckoutExpired,
|
||||||
@@ -20,8 +21,7 @@ import {
|
|||||||
applyFirstPaymentFailed,
|
applyFirstPaymentFailed,
|
||||||
applyFirstPaymentProcessing,
|
applyFirstPaymentProcessing,
|
||||||
applyFirstPaymentSucceeded,
|
applyFirstPaymentSucceeded,
|
||||||
applyInstallmentFailed,
|
applyInstallmentInvoiceFailed,
|
||||||
applyInstallmentPaid,
|
|
||||||
applyRefundState,
|
applyRefundState,
|
||||||
type EventContext,
|
type EventContext,
|
||||||
} from "./payment-events";
|
} from "./payment-events";
|
||||||
@@ -61,16 +61,15 @@ export async function settleCheckoutSession(
|
|||||||
ctx: HandlerContext,
|
ctx: HandlerContext,
|
||||||
actor: Actor = STRIPE_ACTOR,
|
actor: Actor = STRIPE_ACTOR,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { kind, bookingId, installmentId } = session.metadata;
|
const { kind, bookingId } = session.metadata;
|
||||||
if (!bookingId || (kind !== "booking" && kind !== "installment")) return null;
|
if (!bookingId || kind !== "booking") return null;
|
||||||
const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } });
|
const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } });
|
||||||
if (!booking) return null;
|
if (!booking) return null;
|
||||||
// Eine Buchungs-Session muss zur gespeicherten Session passen (oder es ist
|
// Die Session muss zur gespeicherten Session passen (oder es ist noch keine
|
||||||
// noch keine gespeichert, weil das Speichern nach Stripe scheiterte).
|
// gespeichert, weil das Speichern nach Stripe scheiterte).
|
||||||
if (kind === "booking" && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
|
if (booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
|
||||||
|
|
||||||
const paid = session.paymentStatus === "paid";
|
if (session.paymentStatus === "paid") {
|
||||||
if (paid) {
|
|
||||||
if (!session.paymentIntentId) throw new Error(`Bezahlte Session ${session.id} ohne PaymentIntent.`);
|
if (!session.paymentIntentId) throw new Error(`Bezahlte Session ${session.id} ohne PaymentIntent.`);
|
||||||
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
|
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
|
||||||
const info = {
|
const info = {
|
||||||
@@ -80,29 +79,55 @@ export async function settleCheckoutSession(
|
|||||||
customerId: session.customerId,
|
customerId: session.customerId,
|
||||||
amountCents: pi.amountReceived,
|
amountCents: pi.amountReceived,
|
||||||
};
|
};
|
||||||
if (kind === "booking") {
|
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
|
||||||
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
|
await startInstallmentSchedule(db, gateway, bookingId, ctx, actor);
|
||||||
} else if (installmentId) {
|
|
||||||
await db.$transaction((tx) => applyInstallmentPaid(tx, installmentId, info, eventCtx(ctx, actor)));
|
|
||||||
}
|
|
||||||
return bookingId;
|
return bookingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.status === "complete") {
|
if (session.status === "complete") {
|
||||||
if (kind === "booking") {
|
await db.$transaction((tx) =>
|
||||||
await db.$transaction((tx) =>
|
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
|
||||||
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
|
);
|
||||||
);
|
|
||||||
} else if (installmentId) {
|
|
||||||
await db.installment.updateMany({
|
|
||||||
where: { id: installmentId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
|
||||||
data: { status: "PROCESSING", stripePaymentIntentId: session.paymentIntentId, lastAttemptAt: ctx.now },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return bookingId;
|
return bookingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ratenplan bei Stripe anlegen, sobald die erste Rate bezahlt ist. Ein
|
||||||
|
* Fehler hier macht die verbuchte Zahlung nicht rueckgaengig: der Worker
|
||||||
|
* holt das Anlegen beim naechsten Lauf nach.
|
||||||
|
*/
|
||||||
|
async function startInstallmentSchedule(db: Db, gateway: PaymentGateway, bookingId: string, ctx: HandlerContext, actor: Actor) {
|
||||||
|
try {
|
||||||
|
await ensureInstallmentSchedule(db, gateway, bookingId, ctx, actor);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[webhook] Ratenplan fuer Buchung ${bookingId} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstallmentInvoice(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
|
||||||
|
const object = event.data.object as Stripe.Invoice;
|
||||||
|
// Wahrheit bei Stripe nachladen statt dem Event-Inhalt zu vertrauen.
|
||||||
|
const invoice = await gateway.retrieveInvoice(object.id!);
|
||||||
|
const { kind, bookingId } = invoice.metadata;
|
||||||
|
if (kind !== INSTALLMENT_PLAN_KIND || !bookingId) return [];
|
||||||
|
if (event.type === "invoice.paid") {
|
||||||
|
await settleInstallmentInvoice(db, gateway, invoice, ctx, STRIPE_ACTOR);
|
||||||
|
return [bookingId];
|
||||||
|
}
|
||||||
|
if (invoice.status !== "open") return [];
|
||||||
|
await db.$transaction((tx) =>
|
||||||
|
applyInstallmentInvoiceFailed(
|
||||||
|
tx,
|
||||||
|
bookingId,
|
||||||
|
invoice,
|
||||||
|
{ requiresAction: event.type === "invoice.payment_action_required" },
|
||||||
|
eventCtx(ctx),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return [bookingId];
|
||||||
|
}
|
||||||
|
|
||||||
async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
|
async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "checkout.session.completed":
|
case "checkout.session.completed":
|
||||||
@@ -112,73 +137,22 @@ async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ct
|
|||||||
}
|
}
|
||||||
case "checkout.session.async_payment_failed": {
|
case "checkout.session.async_payment_failed": {
|
||||||
const s = sessionInfoFromStripe(event.data.object);
|
const s = sessionInfoFromStripe(event.data.object);
|
||||||
const { kind, bookingId, installmentId } = s.metadata;
|
const { kind, bookingId } = s.metadata;
|
||||||
if (kind === "booking" && bookingId) {
|
if (kind !== "booking" || !bookingId) return [];
|
||||||
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
|
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
|
||||||
return [bookingId];
|
return [bookingId];
|
||||||
}
|
|
||||||
if (kind === "installment" && installmentId) {
|
|
||||||
await db.installment.updateMany({
|
|
||||||
where: { id: installmentId, status: "PROCESSING" },
|
|
||||||
data: { status: "FAILED", failureCode: "async_payment_failed", checkoutSessionId: null, checkoutExpiresAt: null },
|
|
||||||
});
|
|
||||||
return bookingId ? [bookingId] : [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
case "checkout.session.expired": {
|
case "checkout.session.expired": {
|
||||||
const s = event.data.object;
|
const s = event.data.object;
|
||||||
const { kind, bookingId, installmentId } = (s.metadata ?? {}) as Record<string, string>;
|
const { kind, bookingId } = (s.metadata ?? {}) as Record<string, string>;
|
||||||
if (kind === "booking" && bookingId) {
|
if (kind !== "booking" || !bookingId) return [];
|
||||||
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
|
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
|
||||||
return [bookingId];
|
return [bookingId];
|
||||||
}
|
|
||||||
if (kind === "installment" && installmentId) {
|
|
||||||
await db.installment.updateMany({
|
|
||||||
where: { id: installmentId, checkoutSessionId: s.id },
|
|
||||||
data: { checkoutSessionId: null, checkoutExpiresAt: null },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
case "payment_intent.succeeded": {
|
|
||||||
const pi = event.data.object;
|
|
||||||
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
|
|
||||||
const outcome = await db.$transaction((tx) =>
|
|
||||||
applyInstallmentPaid(
|
|
||||||
tx,
|
|
||||||
pi.metadata.installmentId!,
|
|
||||||
{
|
|
||||||
paymentIntentId: pi.id,
|
|
||||||
chargeId: idOf(pi.latest_charge),
|
|
||||||
paymentMethodId: idOf(pi.payment_method),
|
|
||||||
customerId: idOf(pi.customer),
|
|
||||||
amountCents: pi.amount_received,
|
|
||||||
},
|
|
||||||
eventCtx(ctx),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
void outcome;
|
|
||||||
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
|
|
||||||
}
|
|
||||||
case "payment_intent.payment_failed": {
|
|
||||||
const pi = event.data.object;
|
|
||||||
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
|
|
||||||
const code = pi.last_payment_error?.code ?? null;
|
|
||||||
await db.$transaction((tx) =>
|
|
||||||
applyInstallmentFailed(
|
|
||||||
tx,
|
|
||||||
pi.metadata.installmentId!,
|
|
||||||
{
|
|
||||||
paymentIntentId: pi.id,
|
|
||||||
failureCode: pi.last_payment_error?.decline_code ?? code,
|
|
||||||
requiresAction: code === "authentication_required" || pi.status === "requires_action",
|
|
||||||
},
|
|
||||||
eventCtx(ctx),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
|
|
||||||
}
|
}
|
||||||
|
case "invoice.paid":
|
||||||
|
case "invoice.payment_failed":
|
||||||
|
case "invoice.payment_action_required":
|
||||||
|
return handleInstallmentInvoice(db, gateway, event, ctx);
|
||||||
case "charge.refunded": {
|
case "charge.refunded": {
|
||||||
const charge = event.data.object;
|
const charge = event.data.object;
|
||||||
const piId = idOf(charge.payment_intent);
|
const piId = idOf(charge.payment_intent);
|
||||||
@@ -196,8 +170,10 @@ async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ct
|
|||||||
}
|
}
|
||||||
case "charge.dispute.created": {
|
case "charge.dispute.created": {
|
||||||
const piId = idOf(event.data.object.payment_intent);
|
const piId = idOf(event.data.object.payment_intent);
|
||||||
if (piId) await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
|
if (!piId) return [];
|
||||||
return [];
|
await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
|
||||||
|
const disputed = await db.installment.findUnique({ where: { stripePaymentIntentId: piId }, select: { bookingId: true } });
|
||||||
|
return disputed ? [disputed.bookingId] : [];
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
@@ -209,8 +185,9 @@ const HANDLED = new Set<string>([
|
|||||||
"checkout.session.async_payment_succeeded",
|
"checkout.session.async_payment_succeeded",
|
||||||
"checkout.session.async_payment_failed",
|
"checkout.session.async_payment_failed",
|
||||||
"checkout.session.expired",
|
"checkout.session.expired",
|
||||||
"payment_intent.succeeded",
|
"invoice.paid",
|
||||||
"payment_intent.payment_failed",
|
"invoice.payment_failed",
|
||||||
|
"invoice.payment_action_required",
|
||||||
"charge.refunded",
|
"charge.refunded",
|
||||||
"refund.created",
|
"refund.created",
|
||||||
"refund.updated",
|
"refund.updated",
|
||||||
@@ -243,6 +220,14 @@ export async function handleStripeEvent(
|
|||||||
try {
|
try {
|
||||||
const bookingIds = await dispatch(db, gateway, event, ctx);
|
const bookingIds = await dispatch(db, gateway, event, ctx);
|
||||||
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } });
|
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } });
|
||||||
|
// Geriet eine Buchung in Pruefung (Rueckbuchung, Doppelzahlung ...),
|
||||||
|
// zieht Stripe bis zur Entscheidung nichts weiter ein. Scheitert das
|
||||||
|
// hier, erledigt es der Worker.
|
||||||
|
if (bookingIds.length > 0) {
|
||||||
|
await holdSchedulesInReview(db, gateway, bookingIds, STRIPE_ACTOR).catch((error: unknown) => {
|
||||||
|
console.error(`[webhook] Ratenplan nicht angehalten: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
return { outcome: "processed", bookingIds };
|
return { outcome: "processed", bookingIds };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler";
|
const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler";
|
||||||
|
|||||||
+66
-172
@@ -1,25 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Hintergrundaufgaben, ausgefuehrt vom Kubernetes-CronJob (scripts/worker.ts).
|
* Hintergrundaufgaben, ausgefuehrt vom Kubernetes-CronJob (scripts/worker.ts).
|
||||||
*
|
*
|
||||||
* Kein In-Process-Timer: Faelligkeiten stehen in der Datenbank, jeder Lauf
|
* Kein In-Process-Timer: jeder Lauf arbeitet den aktuellen Stand ab. Die
|
||||||
* arbeitet den aktuellen Stand ab. Mehrere gleichzeitige Laeufe (z. B. ein
|
* Folgeraten zieht Stripe Billing ein; der Worker legt nur fehlende
|
||||||
* manueller Start waehrend des CronJobs) sind unschaedlich, weil jede Rate
|
* Ratenplaene an und gleicht verlorene Webhooks ab. Mehrere gleichzeitige
|
||||||
* vor dem Einzug atomar beansprucht wird und Stripe-Aufrufe
|
* Laeufe sind unschaedlich, weil Stripe-Aufrufe Idempotenzschluessel tragen
|
||||||
* Idempotenzschluessel tragen.
|
* und jede Verbuchung idempotent ist.
|
||||||
*/
|
*/
|
||||||
import { audit, WORKER } from "./audit";
|
import { audit, WORKER } from "./audit";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
import { flushEmails } from "./email";
|
import { flushEmails } from "./email";
|
||||||
import { MAX_OFF_SESSION_ATTEMPTS, applyCheckoutExpired, applyFirstPaymentFailed, applyInstallmentFailed, applyInstallmentPaid, type EventContext } from "./payment-events";
|
import { eachBooking, ensureInstallmentSchedule, holdSchedulesInReview, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing";
|
||||||
|
import { applyCheckoutExpired, applyFirstPaymentFailed, type EventContext } from "./payment-events";
|
||||||
import type { PaymentGateway } from "./payment-gateway";
|
import type { PaymentGateway } from "./payment-gateway";
|
||||||
import { purgeRateLimits } from "./rate-limit";
|
import { purgeRateLimits } from "./rate-limit";
|
||||||
import { settleCheckoutSession, type HandlerContext } from "./webhook";
|
import { settleCheckoutSession, type HandlerContext } from "./webhook";
|
||||||
|
|
||||||
/** Stripe-Idempotenzschluessel gelten 24 Stunden; ein haengender Einzug
|
/** Stripe stellt Rechnungen erst rund eine Stunde nach Faelligkeit fertig. */
|
||||||
* wird nur innerhalb dieser Frist automatisch mit demselben Schluessel
|
const RECONCILE_AFTER_MS = 6 * 60 * 60_000;
|
||||||
* wiederholt, danach entscheidet ein Mensch. */
|
|
||||||
const SAFE_RETRY_WINDOW_MS = 20 * 60 * 60_000;
|
|
||||||
const STUCK_AFTER_MS = 2 * 60 * 60_000;
|
|
||||||
|
|
||||||
function eventCtx(ctx: HandlerContext): EventContext {
|
function eventCtx(ctx: HandlerContext): EventContext {
|
||||||
return { ...ctx, actor: WORKER };
|
return { ...ctx, actor: WORKER };
|
||||||
@@ -61,181 +59,71 @@ export async function releaseExpiredReservations(db: Db, gateway: PaymentGateway
|
|||||||
return { released, settled };
|
return { released, settled };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zieht faellige Folgeraten off-session ein. */
|
/**
|
||||||
export async function chargeDueInstallments(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
* Legt den Stripe-Ratenplan fuer bestaetigte Ratenbuchungen an, falls das
|
||||||
const livemode = gateway.mode === "live";
|
* direkt nach der ersten Zahlung scheiterte oder die Buchung erst spaeter
|
||||||
const due = await db.installment.findMany({
|
* bestaetigt wurde (Pruefung im Adminbereich).
|
||||||
|
*/
|
||||||
|
export async function createMissingSchedules(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||||
|
const bookings = await db.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
seq: { gt: 1 },
|
status: "CONFIRMED",
|
||||||
booking: { status: "CONFIRMED", livemode },
|
planType: { not: "FULL" },
|
||||||
OR: [
|
stripeScheduleId: null,
|
||||||
{ status: "PENDING", dueAt: { lte: ctx.now } },
|
livemode: gateway.mode === "live",
|
||||||
{ status: "FAILED", nextAttemptAt: { lte: ctx.now }, attemptCount: { lt: MAX_OFF_SESSION_ATTEMPTS } },
|
installments: { some: { seq: { gt: 1 }, status: "PENDING" } },
|
||||||
],
|
|
||||||
AND: [{ OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }] }],
|
|
||||||
},
|
},
|
||||||
orderBy: { dueAt: "asc" },
|
select: { id: true },
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
|
const summary = { created: 0, needsReview: 0, failed: 0 };
|
||||||
const summary = { charged: 0, failed: 0, requiresAction: 0, processing: 0, skipped: 0 };
|
for (const booking of bookings) {
|
||||||
for (const installment of due) {
|
|
||||||
// Atomar beanspruchen: nur ein Lauf darf diese Rate einziehen.
|
|
||||||
const claimed = await db.installment.updateMany({
|
|
||||||
where: {
|
|
||||||
id: installment.id,
|
|
||||||
status: installment.status,
|
|
||||||
OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }],
|
|
||||||
},
|
|
||||||
data: { status: "PROCESSING", lastAttemptAt: ctx.now },
|
|
||||||
});
|
|
||||||
if (claimed.count === 0) {
|
|
||||||
summary.skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const booking = await db.booking.findUniqueOrThrow({ where: { id: installment.bookingId } });
|
|
||||||
|
|
||||||
// Schutz gegen jede Abbuchung ueber den vereinbarten Gesamtpreis hinaus,
|
|
||||||
// insbesondere nach der letzten Rate.
|
|
||||||
if (booking.paidCents + installment.amountCents > booking.totalCents) {
|
|
||||||
await db.installment.update({ where: { id: installment.id }, data: { status: "CANCELED", failureCode: "plan_already_paid" } });
|
|
||||||
await audit(db, WORKER, "installment.skipped_overpayment_guard", "Installment", installment.id, {
|
|
||||||
paidCents: booking.paidCents,
|
|
||||||
totalCents: booking.totalCents,
|
|
||||||
});
|
|
||||||
summary.skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
|
|
||||||
await db.$transaction((tx) =>
|
|
||||||
applyInstallmentFailed(tx, installment.id, { paymentIntentId: null, failureCode: "no_saved_payment_method", requiresAction: true }, eventCtx(ctx)),
|
|
||||||
);
|
|
||||||
summary.requiresAction++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
try {
|
||||||
result = await gateway.chargeOffSession(
|
const outcome = await ensureInstallmentSchedule(db, gateway, booking.id, ctx, WORKER);
|
||||||
{
|
if (outcome === "created") summary.created++;
|
||||||
customerId: booking.stripeCustomerId,
|
if (outcome === "needs_review") summary.needsReview++;
|
||||||
paymentMethodId: booking.stripePaymentMethodId,
|
|
||||||
amountCents: installment.amountCents,
|
|
||||||
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Rate ${installment.seq}`,
|
|
||||||
metadata: {
|
|
||||||
kind: "installment_offsession",
|
|
||||||
bookingId: booking.id,
|
|
||||||
installmentId: installment.id,
|
|
||||||
reference: booking.reference,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Gleicher Schluessel bei Wiederholung nach Netzwerkfehler: Stripe
|
|
||||||
// liefert dann das erste Ergebnis statt ein zweites Mal abzubuchen.
|
|
||||||
`offsession:${installment.id}:${installment.attemptCount}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[worker] Einzug Rate ${installment.id} unterbrochen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
console.error(`[worker] Ratenplan fuer Buchung ${booking.id} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||||
await db.installment.updateMany({
|
|
||||||
where: { id: installment.id, status: "PROCESSING" },
|
|
||||||
data: { status: installment.status, nextAttemptAt: new Date(ctx.now.getTime() + 60 * 60_000) },
|
|
||||||
});
|
|
||||||
summary.failed++;
|
summary.failed++;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.outcome === "succeeded" && result.paymentIntentId) {
|
|
||||||
await db.$transaction((tx) =>
|
|
||||||
applyInstallmentPaid(
|
|
||||||
tx,
|
|
||||||
installment.id,
|
|
||||||
{
|
|
||||||
paymentIntentId: result.paymentIntentId!,
|
|
||||||
chargeId: result.chargeId,
|
|
||||||
paymentMethodId: booking.stripePaymentMethodId,
|
|
||||||
customerId: booking.stripeCustomerId,
|
|
||||||
amountCents: installment.amountCents,
|
|
||||||
},
|
|
||||||
eventCtx(ctx),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
summary.charged++;
|
|
||||||
} else if (result.outcome === "processing") {
|
|
||||||
await db.installment.update({ where: { id: installment.id }, data: { stripePaymentIntentId: result.paymentIntentId } });
|
|
||||||
summary.processing++;
|
|
||||||
} else {
|
|
||||||
await db.$transaction((tx) =>
|
|
||||||
applyInstallmentFailed(
|
|
||||||
tx,
|
|
||||||
installment.id,
|
|
||||||
{
|
|
||||||
paymentIntentId: result.paymentIntentId,
|
|
||||||
failureCode: result.failureCode,
|
|
||||||
requiresAction: result.outcome === "requires_action",
|
|
||||||
},
|
|
||||||
eventCtx(ctx),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (result.outcome === "requires_action") summary.requiresAction++;
|
|
||||||
else summary.failed++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Faengt haengende Einzuege und verlorene Webhooks auf. */
|
/**
|
||||||
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
* Faengt verlorene Rechnungs-Webhooks auf: Ist eine Folgerate seit
|
||||||
const stuck = await db.installment.findMany({
|
* RECONCILE_AFTER_MS faellig und nicht verbucht, fragt der Worker Stripe
|
||||||
where: { status: "PROCESSING", lastAttemptAt: { lt: new Date(ctx.now.getTime() - STUCK_AFTER_MS) }, seq: { gt: 1 } },
|
* nach den Rechnungen des Zeitplans und verbucht bezahlte.
|
||||||
include: { booking: { select: { livemode: true } } },
|
*/
|
||||||
|
export async function reconcileInstallmentInvoices(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||||
|
const bookings = await db.booking.findMany({
|
||||||
|
where: {
|
||||||
|
stripeScheduleId: { not: null },
|
||||||
|
livemode: gateway.mode === "live",
|
||||||
|
installments: {
|
||||||
|
some: {
|
||||||
|
seq: { gt: 1 },
|
||||||
|
status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] },
|
||||||
|
dueAt: { lt: new Date(ctx.now.getTime() - RECONCILE_AFTER_MS) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, stripeScheduleId: true },
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
let resolved = 0;
|
let settled = 0;
|
||||||
for (const installment of stuck) {
|
const failed = await eachBooking(bookings, "abgleichen", async (booking) => {
|
||||||
if (installment.booking.livemode !== (gateway.mode === "live")) continue;
|
const invoices = await gateway.listScheduleInvoices(booking.stripeScheduleId!);
|
||||||
if (installment.stripePaymentIntentId) {
|
for (const invoice of invoices.filter((i) => i.status === "paid")) {
|
||||||
const pi = await gateway.retrievePaymentIntent(installment.stripePaymentIntentId);
|
if (await settleInstallmentInvoice(db, gateway, invoice, ctx, WORKER)) settled++;
|
||||||
if (pi.status === "succeeded") {
|
|
||||||
await db.$transaction((tx) =>
|
|
||||||
applyInstallmentPaid(
|
|
||||||
tx,
|
|
||||||
installment.id,
|
|
||||||
{ paymentIntentId: pi.id, chargeId: pi.chargeId, paymentMethodId: pi.paymentMethodId, customerId: null, amountCents: pi.amountReceived },
|
|
||||||
eventCtx(ctx),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
resolved++;
|
|
||||||
} else if (pi.status === "requires_payment_method" || pi.status === "canceled" || pi.status === "requires_action") {
|
|
||||||
await db.$transaction((tx) =>
|
|
||||||
applyInstallmentFailed(
|
|
||||||
tx,
|
|
||||||
installment.id,
|
|
||||||
{ paymentIntentId: pi.id, failureCode: `stuck_${pi.status}`, requiresAction: pi.status === "requires_action" },
|
|
||||||
eventCtx(ctx),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
resolved++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
// Kein Ergebnis gespeichert (z. B. Absturz waehrend des Aufrufs).
|
});
|
||||||
if (installment.lastAttemptAt && ctx.now.getTime() - installment.lastAttemptAt.getTime() < SAFE_RETRY_WINDOW_MS) {
|
return { checked: bookings.length, settled, failed };
|
||||||
// attemptCount bleibt gleich -> naechster Lauf nutzt denselben
|
}
|
||||||
// Idempotenzschluessel und kann nicht doppelt abbuchen.
|
|
||||||
await db.installment.updateMany({
|
|
||||||
where: { id: installment.id, status: "PROCESSING" },
|
|
||||||
data: { status: "FAILED", nextAttemptAt: ctx.now, failureCode: "worker_interrupted" },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await db.installment.updateMany({
|
|
||||||
where: { id: installment.id, status: "PROCESSING" },
|
|
||||||
data: { status: "REQUIRES_ACTION", failureCode: "manual_check_required" },
|
|
||||||
});
|
|
||||||
await audit(db, WORKER, "installment.manual_check_required", "Installment", installment.id);
|
|
||||||
}
|
|
||||||
resolved++;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Faengt verzoegerte erste Zahlungen (SEPA) auf, deren Ergebnis-Webhook ausblieb. */
|
||||||
|
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||||
|
let resolved = 0;
|
||||||
// Verzoegerte erste Zahlungen (SEPA), deren Ergebnis-Webhook ausblieb.
|
// Verzoegerte erste Zahlungen (SEPA), deren Ergebnis-Webhook ausblieb.
|
||||||
const processing = await db.booking.findMany({
|
const processing = await db.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -276,6 +164,9 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
|
|||||||
livemode: gateway.mode === "live",
|
livemode: gateway.mode === "live",
|
||||||
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
||||||
installments: { none: { status: { in: ["PENDING", "PROCESSING", "FAILED", "REQUIRES_ACTION"] } } },
|
installments: { none: { status: { in: ["PENDING", "PROCESSING", "FAILED", "REQUIRES_ACTION"] } } },
|
||||||
|
// Stornierte Buchungen mit Stripe-Ratenplan: erst den Plan beenden
|
||||||
|
// (stopCanceledInstallmentPlans), sonst zoege Stripe weiter ein.
|
||||||
|
NOT: { status: "CANCELED", stripeScheduleId: { not: null } },
|
||||||
},
|
},
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
@@ -290,9 +181,12 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
|
|||||||
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||||
const reservations = await releaseExpiredReservations(db, gateway, ctx);
|
const reservations = await releaseExpiredReservations(db, gateway, ctx);
|
||||||
const reconcile = await reconcileStuck(db, gateway, ctx);
|
const reconcile = await reconcileStuck(db, gateway, ctx);
|
||||||
const installments = await chargeDueInstallments(db, gateway, ctx);
|
const held = await holdSchedulesInReview(db, gateway);
|
||||||
|
const schedules = await createMissingSchedules(db, gateway, ctx);
|
||||||
|
const installments = await reconcileInstallmentInvoices(db, gateway, ctx);
|
||||||
|
const stopped = await stopCanceledInstallmentPlans(db, gateway);
|
||||||
const detach = await detachFinishedPaymentMethods(db, gateway);
|
const detach = await detachFinishedPaymentMethods(db, gateway);
|
||||||
const emails = await flushEmails(db, { limit: 200 });
|
const emails = await flushEmails(db, { limit: 200 });
|
||||||
const purged = await purgeRateLimits(db);
|
const purged = await purgeRateLimits(db);
|
||||||
return { reservations, reconcile, installments, detach, emails, purgedRateLimits: purged };
|
return { reservations, reconcile, held, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, test } from "vitest";
|
|||||||
import { cancelBooking, refundInstallment, saveVoucher, voucherInputSchema } from "@/lib/admin-service";
|
import { cancelBooking, refundInstallment, saveVoucher, voucherInputSchema } from "@/lib/admin-service";
|
||||||
import { startCheckout } from "@/lib/checkout";
|
import { startCheckout } from "@/lib/checkout";
|
||||||
import { handleStripeEvent } from "@/lib/webhook";
|
import { handleStripeEvent } from "@/lib/webhook";
|
||||||
import { chargeDueInstallments } from "@/lib/worker-jobs";
|
import { stopCanceledInstallmentPlans } from "@/lib/installment-billing";
|
||||||
|
import { detachFinishedPaymentMethods } from "@/lib/worker-jobs";
|
||||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||||
import { FakeGateway } from "./support/fake-gateway";
|
import { FakeGateway } from "./support/fake-gateway";
|
||||||
|
|
||||||
@@ -33,8 +34,10 @@ async function paidInstallmentBooking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Stornierung", () => {
|
describe("Stornierung", () => {
|
||||||
test("stoppt alle weiteren Abbuchungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
test("beendet den Stripe-Ratenplan, storniert offene Rechnungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||||
const booking = await paidInstallmentBooking();
|
const booking = await paidInstallmentBooking();
|
||||||
|
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||||
|
const open = gateway.billNextInstallment(schedule.id, "failed");
|
||||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
@@ -42,14 +45,34 @@ describe("Stornierung", () => {
|
|||||||
expect(after.status).toBe("CANCELED");
|
expect(after.status).toBe("CANCELED");
|
||||||
expect(after.reservation!.status).toBe("RELEASED");
|
expect(after.reservation!.status).toBe("RELEASED");
|
||||||
expect(after.installments.filter((i) => i.seq > 1).every((i) => i.status === "CANCELED")).toBe(true);
|
expect(after.installments.filter((i) => i.seq > 1).every((i) => i.status === "CANCELED")).toBe(true);
|
||||||
|
expect(schedule.status).toBe("canceled");
|
||||||
|
expect((await gateway.retrieveInvoice(open.id)).status).toBe("void");
|
||||||
|
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
|
||||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||||
|
|
||||||
const later = { ...ctx, now: new Date("2027-06-01T12:00:00Z") };
|
|
||||||
await chargeDueInstallments(db, gateway, later);
|
|
||||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
|
||||||
expect(await db.auditLog.count({ where: { action: "booking.canceled", actorId: "admin-test" } })).toBe(1);
|
expect(await db.auditLog.count({ where: { action: "booking.canceled", actorId: "admin-test" } })).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Stripe nicht erreichbar: Storno bleibt, der Worker beendet den Ratenplan spaeter", async () => {
|
||||||
|
const booking = await paidInstallmentBooking();
|
||||||
|
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||||
|
const cancel = gateway.cancelInstallmentSchedule.bind(gateway);
|
||||||
|
gateway.cancelInstallmentSchedule = async () => {
|
||||||
|
throw new Error("Stripe nicht erreichbar (simuliert)");
|
||||||
|
};
|
||||||
|
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(schedule.status).toBe("active");
|
||||||
|
// Der allgemeine Lauf trennt die Zahlungsmethode nicht, solange der Plan laeuft.
|
||||||
|
await detachFinishedPaymentMethods(db, gateway);
|
||||||
|
expect(gateway.detached).toEqual([]);
|
||||||
|
|
||||||
|
gateway.cancelInstallmentSchedule = cancel;
|
||||||
|
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(1);
|
||||||
|
expect(schedule.status).toBe("canceled");
|
||||||
|
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||||
|
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("verlangt einen Grund", async () => {
|
test("verlangt einen Grund", async () => {
|
||||||
const booking = await paidInstallmentBooking();
|
const booking = await paidInstallmentBooking();
|
||||||
expect((await cancelBooking(db, gateway, { bookingId: booking.id, reason: " " }, admin)).ok).toBe(false);
|
expect((await cancelBooking(db, gateway, { bookingId: booking.id, reason: " " }, admin)).ok).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const h = (values: Record<string, string>) => new Headers(values);
|
||||||
|
|
||||||
|
describe("clientIp hinter Traefik", () => {
|
||||||
|
test("Gegenstelle ist der letzte Eintrag in X-Forwarded-For, nie ein Eintrag des Clients", () => {
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", "x-real-ip": "1.2.3.4" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("über Cloudflare gefälschtes X-Real-IP zählt nicht, CF-Connecting-IP nur von Cloudflare-Edges", () => {
|
||||||
|
const viaCloudflare = { "x-real-ip": "1.2.3.4", "x-forwarded-for": "1.2.3.4, 203.0.113.7, 172.70.1.2", "cf-connecting-ip": "203.0.113.7" };
|
||||||
|
expect(clientIp(h(viaCloudflare))).toBe("203.0.113.7");
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "198.51.100.9", "cf-connecting-ip": "203.0.113.7" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne X-Forwarded-For (lokal) gilt X-Real-IP, ungültige Werte ergeben „unknown“", () => {
|
||||||
|
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
expect(clientIp(h({}))).toBe("unknown");
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, kein-ip" }))).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Rate-Limits zählen IPv6 je /64-Netz, IPv4-gemappte Adressen wie IPv4", () => {
|
||||||
|
expect(rateLimitSubject(clientIp(h({ "x-forwarded-for": "2001:db8:1:2:aaaa::1" })))).toBe("2001:db8:1:2::/64");
|
||||||
|
expect(rateLimitSubject("2001:0DB8:0001:0002:ffff:ffff:ffff:ffff")).toBe("2001:db8:1:2::/64");
|
||||||
|
expect(rateLimitSubject("2001:db8:1:3::1")).not.toBe(rateLimitSubject("2001:db8:1:2::1"));
|
||||||
|
expect(rateLimitSubject("::ffff:198.51.100.9")).toBe("198.51.100.9");
|
||||||
|
expect(rateLimitSubject("198.51.100.9")).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("better-auth bekommt eine einzelne gültige IP (es normalisiert IPv6 selbst auf /64)", async () => {
|
||||||
|
const request = new Request("https://example.org/api/auth/sign-in/email", { headers: { "x-forwarded-for": "2001:db8:1:2:aaaa::1" } });
|
||||||
|
expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("2001:db8:1:2:aaaa::1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withClientIp setzt den Header für better-auth und überschreibt einen mitgeschickten Wert", async () => {
|
||||||
|
const request = new Request("https://example.org/api/auth/sign-in/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withClientIp verträgt Nexts Proxy um NextRequest (Node 24, undici 7) und behält Methode und Body", async () => {
|
||||||
|
// Next 16 reicht Route-Handlern einen Proxy, dessen Methoden an das Original gebunden sind.
|
||||||
|
// undici 7 prüft private Felder (#state); ein Proxy besteht diese Prüfung nicht.
|
||||||
|
const original = new Request("https://example.org/api/auth/sign-in/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-forwarded-for": "198.51.100.9", "content-type": "application/json" },
|
||||||
|
body: '{"email":"[email protected]"}',
|
||||||
|
});
|
||||||
|
const wieNext = new Proxy(original, {
|
||||||
|
get(target, key) {
|
||||||
|
const wert = Reflect.get(target, key, target);
|
||||||
|
return typeof wert === "function" ? wert.bind(target) : wert;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kopie = await withClientIp(wieNext);
|
||||||
|
expect(kopie.method).toBe("POST");
|
||||||
|
expect(kopie.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
|
||||||
|
expect(await kopie.text()).toBe('{"email":"[email protected]"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => {
|
||||||
|
const edge = "172.70.1.2";
|
||||||
|
const viaEdge = (cf: string) => clientIp(h({ "x-forwarded-for": `1.2.3.4, ${edge}`, "cf-connecting-ip": cf }));
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"0.1.2.3", "10.0.0.5", "100.64.0.1", "100.127.255.255", "127.0.0.1", "169.254.1.1", "172.16.0.1", "172.31.255.255",
|
||||||
|
"192.168.1.1", "240.0.0.1", "255.255.255.255", "::", "::1", "fc00::1", "fd12::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:192.168.0.1",
|
||||||
|
])("%s wird verworfen, die Gegenstelle gilt", (cf) => {
|
||||||
|
expect(viaEdge(cf)).toBe(edge);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "2001:db8::1", "::ffff:203.0.113.7"])("%s wird übernommen", (cf) => {
|
||||||
|
expect(viaEdge(cf)).toBe(cf);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("X-Real-IP in Produktion", () => {
|
||||||
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
test("ohne X-Forwarded-For ergibt X-Real-IP in Produktion „unknown“", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown");
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("außerhalb von Produktion gilt X-Real-IP weiter", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("headersWithClientIp", () => {
|
||||||
|
test("überschreibt einen mitgeschickten Wert und lässt das Original unverändert", () => {
|
||||||
|
const original = h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" });
|
||||||
|
const result = headersWithClientIp(original);
|
||||||
|
expect(result.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
|
||||||
|
expect(original.get(CLIENT_IP_HEADER)).toBe("1.2.3.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne ermittelbare IP steht „unknown“ im Header, nie der Clientwert", () => {
|
||||||
|
expect(headersWithClientIp(h({ [CLIENT_IP_HEADER]: "1.2.3.4" })).get(CLIENT_IP_HEADER)).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { contactRequestSchema, submitContactRequest } from "@/lib/contact";
|
||||||
|
import { resetEnvCache } from "@/lib/env";
|
||||||
|
import { db, resetDb, SECRET, setupCamp } from "./support/db";
|
||||||
|
|
||||||
|
const NOW = new Date("2026-11-15T10:00:00Z");
|
||||||
|
const ctx = { now: NOW, secret: SECRET };
|
||||||
|
|
||||||
|
function request(overrides: Record<string, unknown> = {}) {
|
||||||
|
return contactRequestSchema.parse({
|
||||||
|
name: "Lena Beispiel",
|
||||||
|
email: "[email protected]",
|
||||||
|
phone: "+49 170 1234567",
|
||||||
|
availability: "abends",
|
||||||
|
message: "Passt das Camp für Einsteigerinnen?",
|
||||||
|
privacy: true,
|
||||||
|
website: "",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
await setupCamp({ organizerEmail: "[email protected]" });
|
||||||
|
delete process.env.ADMIN_NOTIFY_EMAIL;
|
||||||
|
resetEnvCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Erstgespräch", () => {
|
||||||
|
test("Anfrage landet als Mail beim Veranstalter, mit allen Angaben", async () => {
|
||||||
|
expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: true, delivered: true });
|
||||||
|
const mails = await db.emailMessage.findMany();
|
||||||
|
expect(mails).toHaveLength(1);
|
||||||
|
expect(mails[0]!.toAddress).toBe("[email protected]");
|
||||||
|
expect(mails[0]!.subject).toContain("Lena Beispiel");
|
||||||
|
expect(mails[0]!.textBody).toContain("[email protected]");
|
||||||
|
expect(mails[0]!.textBody).toContain("Passt das Camp für Einsteigerinnen?");
|
||||||
|
expect(mails[0]!.htmlBody).toContain("mailto:lena%40example.org");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doppelt abgeschickt ergibt nur eine Mail", async () => {
|
||||||
|
await submitContactRequest(db, request(), ctx);
|
||||||
|
await submitContactRequest(db, request(), { ...ctx, now: new Date(NOW.getTime() + 10_000) });
|
||||||
|
expect(await db.emailMessage.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bot im Honeypot: Erfolg vortäuschen, nichts verschicken", async () => {
|
||||||
|
expect(await submitContactRequest(db, request({ website: "https://spam.example" }), ctx)).toEqual({ ok: true, delivered: false });
|
||||||
|
expect(await db.emailMessage.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne Empfänger nicht verfügbar, mit ADMIN_NOTIFY_EMAIL als Ersatz", async () => {
|
||||||
|
await setupCamp({ organizerEmail: null });
|
||||||
|
expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: false, error: "NOT_CONFIGURED" });
|
||||||
|
process.env.ADMIN_NOTIFY_EMAIL = "[email protected]";
|
||||||
|
resetEnvCache();
|
||||||
|
await submitContactRequest(db, request(), ctx);
|
||||||
|
expect((await db.emailMessage.findFirstOrThrow()).toAddress).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => {
|
||||||
|
expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false);
|
||||||
|
expect(contactRequestSchema.safeParse({ name: "Lena", email: "[email protected]", privacy: false }).success).toBe(false);
|
||||||
|
const base = { name: "Lena", email: "[email protected]", privacy: true };
|
||||||
|
expect(contactRequestSchema.safeParse(base).success).toBe(false);
|
||||||
|
expect(contactRequestSchema.safeParse({ ...base, phone: " " }).success).toBe(false);
|
||||||
|
expect(contactRequestSchema.safeParse({ ...base, phone: "abc123456" }).success).toBe(false);
|
||||||
|
expect(request({ phone: " +49 170 1234567 " }).phone).toBe("+49 170 1234567");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { enqueueEmail, flushEmails } from "@/lib/email";
|
||||||
|
import { resetEnvCache } from "@/lib/env";
|
||||||
|
import { db, resetDb } from "./support/db";
|
||||||
|
|
||||||
|
const sendMail = vi.fn();
|
||||||
|
|
||||||
|
// SMTP-Versand simulieren; der Datei-Transport (streamTransport) bleibt echt.
|
||||||
|
vi.mock("nodemailer", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("nodemailer")>();
|
||||||
|
const createTransport = (options: Record<string, unknown>) =>
|
||||||
|
options.streamTransport ? actual.default.createTransport(options) : { sendMail };
|
||||||
|
return { ...actual, default: { ...actual.default, createTransport } };
|
||||||
|
});
|
||||||
|
|
||||||
|
function smtpError(message: string, responseCode?: number, code = "EENVELOPE") {
|
||||||
|
return Object.assign(new Error(message), { code, responseCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueMail(to = "[email protected]") {
|
||||||
|
await enqueueEmail(db, { dedupeKey: `test:${to}`, to, subject: "Buchung bestätigt", text: "Hallo", html: "<p>Hallo</p>" });
|
||||||
|
return db.emailMessage.findFirstOrThrow({ where: { toAddress: to } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
process.env.MAIL_TRANSPORT = "smtp";
|
||||||
|
process.env.SMTP_HOST = "smtp.example.org";
|
||||||
|
resetEnvCache();
|
||||||
|
sendMail.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.MAIL_TRANSPORT;
|
||||||
|
delete process.env.SMTP_HOST;
|
||||||
|
resetEnvCache();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Postausgang: Fehler beim SMTP-Versand", () => {
|
||||||
|
test("dauerhafte Ablehnung (5xx) wird sofort FAILED und nie wiederholt", async () => {
|
||||||
|
const mail = await queueMail();
|
||||||
|
sendMail.mockRejectedValue(
|
||||||
|
smtpError("Can't send mail - all recipients were rejected: 556 5.1.10 <[email protected]>: Recipient address rejected: Domain test.de does not accept mail (nullMX)", 556),
|
||||||
|
);
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 1 });
|
||||||
|
const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } });
|
||||||
|
expect(after.status).toBe("FAILED");
|
||||||
|
expect(after.attempts).toBe(1);
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 0 });
|
||||||
|
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Log und gespeicherter Fehler enthalten keine E-Mail-Adresse", async () => {
|
||||||
|
await queueMail();
|
||||||
|
sendMail.mockRejectedValue(smtpError("all recipients were rejected: 556 5.1.10 <[email protected]>: Recipient address rejected", 556));
|
||||||
|
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await flushEmails(db);
|
||||||
|
const logged = log.mock.calls.flat().join(" ");
|
||||||
|
expect(logged).toContain("556 5.1.10");
|
||||||
|
expect(logged).not.toContain("[email protected]");
|
||||||
|
expect((await db.emailMessage.findFirstOrThrow()).lastError).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vorübergehende Fehler (4xx, Verbindung) bleiben PENDING und werden wiederholt", async () => {
|
||||||
|
const greylisted = await queueMail("[email protected]");
|
||||||
|
const offline = await queueMail("[email protected]");
|
||||||
|
sendMail
|
||||||
|
.mockRejectedValueOnce(smtpError("421 4.7.0 Try again later", 421))
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNECTION" }));
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 });
|
||||||
|
for (const id of [greylisted.id, offline.id]) {
|
||||||
|
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Server- und Absenderfehler (Anmeldung, Richtlinie) bleiben PENDING statt FAILED", async () => {
|
||||||
|
const auth = await queueMail("[email protected]");
|
||||||
|
const policy = await queueMail("[email protected]");
|
||||||
|
sendMail
|
||||||
|
.mockRejectedValueOnce(smtpError("535 5.7.8 Authentication failed", 535, "EAUTH"))
|
||||||
|
.mockRejectedValueOnce(smtpError("554 5.7.1 Message rejected as spam", 554, "EMESSAGE"));
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 });
|
||||||
|
for (const id of [auth.id, policy.id]) {
|
||||||
|
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("EENVELOPE mit 550/551/553/556 oder 5.1.x ist endgueltig, 552 und 4xx nicht", async () => {
|
||||||
|
const cases: [number, string, boolean][] = [
|
||||||
|
[550, "550 5.1.1 User unknown", true],
|
||||||
|
[551, "551 User not local", true],
|
||||||
|
[553, "553 Mailbox name not allowed", true],
|
||||||
|
[554, "554 5.1.2 Bad destination system address", true],
|
||||||
|
[552, "552 Mailbox full", false],
|
||||||
|
[451, "451 4.1.1 Try later", false],
|
||||||
|
];
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
for (const [index, [code, response, permanent]] of cases.entries()) {
|
||||||
|
await resetDb();
|
||||||
|
const mail = await queueMail(`r${index}@example.org`);
|
||||||
|
sendMail.mockRejectedValueOnce(Object.assign(smtpError(response, code), { response }));
|
||||||
|
await flushEmails(db);
|
||||||
|
const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } });
|
||||||
|
expect(after.status, response).toBe(permanent ? "FAILED" : "PENDING");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { beforeEach, describe, expect, test } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
import { startCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
import { installmentPaymentUrl, startCheckout } from "@/lib/checkout";
|
||||||
import { applyRefundState, MAX_OFF_SESSION_ATTEMPTS } from "@/lib/payment-events";
|
import { applyRefundState } from "@/lib/payment-events";
|
||||||
import { verifyStripeWebhook } from "@/lib/payment-gateway";
|
import { verifyStripeWebhook } from "@/lib/payment-gateway";
|
||||||
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
|
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
|
||||||
import { chargeDueInstallments, detachFinishedPaymentMethods, releaseExpiredReservations } from "@/lib/worker-jobs";
|
import { createMissingSchedules, detachFinishedPaymentMethods, reconcileInstallmentInvoices, releaseExpiredReservations } from "@/lib/worker-jobs";
|
||||||
import { STRIPE_ACTOR } from "@/lib/audit";
|
import { STRIPE_ACTOR } from "@/lib/audit";
|
||||||
|
import { confirmReviewedBooking } from "@/lib/admin-service";
|
||||||
import type { PlanType } from "@/lib/payment-plan";
|
import type { PlanType } from "@/lib/payment-plan";
|
||||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||||
import { FakeGateway } from "./support/fake-gateway";
|
import { FakeGateway } from "./support/fake-gateway";
|
||||||
@@ -212,113 +213,187 @@ describe("Verzoegerte und verspaetete Zahlung", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Folgeraten", () => {
|
describe("Folgeraten ueber Stripe Billing", () => {
|
||||||
const afterLastDue = (booking: { installments: { dueAt: Date | null }[] }) => ({
|
function invoiceEvent(type: string, invoice: { id: string }, id = `evt_${crypto.randomUUID()}`) {
|
||||||
...ctx,
|
return { id, type, livemode: false, data: { object: { id: invoice.id, object: "invoice" } } } as unknown as Stripe.Event;
|
||||||
now: new Date(Math.max(...booking.installments.map((i) => i.dueAt?.getTime() ?? 0)) + 60_000),
|
}
|
||||||
|
|
||||||
|
async function scheduleOf(bookingId: string) {
|
||||||
|
const booking = await db.booking.findUniqueOrThrow({ where: { id: bookingId } });
|
||||||
|
return gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installment(bookingId: string, seq: number) {
|
||||||
|
return db.installment.findFirstOrThrow({ where: { bookingId, seq } });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("erste Rate bezahlt: Stripe-Ratenplan mit Betrag, Start, Abstand und Zahlungsmethode", async () => {
|
||||||
|
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||||
|
const [, second, third] = booking.installments;
|
||||||
|
const schedule = await scheduleOf(booking.id);
|
||||||
|
expect(schedule.params).toMatchObject({
|
||||||
|
amountCents: 70_000,
|
||||||
|
count: 2,
|
||||||
|
startAt: second!.dueAt,
|
||||||
|
intervalDays: Math.round((third!.dueAt!.getTime() - second!.dueAt!.getTime()) / 86_400_000),
|
||||||
|
paymentMethodId: booking.stripePaymentMethodId,
|
||||||
|
customerId: booking.stripeCustomerId,
|
||||||
|
});
|
||||||
|
expect(schedule.params.metadata).toMatchObject({ kind: "installment_plan", bookingId: booking.id });
|
||||||
|
|
||||||
|
// Erneut zugestelltes Checkout-Event legt keinen zweiten Plan an.
|
||||||
|
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
|
||||||
|
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_succeeded", session), ctx);
|
||||||
|
expect(gateway.schedules.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Einzug aller Raten, danach keine weitere Abbuchung und Zahlungsmethode getrennt", async () => {
|
test("Einmalzahlung legt keinen Ratenplan an", async () => {
|
||||||
|
await paidBooking("FULL");
|
||||||
|
expect(gateway.schedules.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Stripe zieht alle Raten ein; danach keine weitere Rechnung, Zahlungsmethode getrennt", async () => {
|
||||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||||
const runCtx = afterLastDue(booking);
|
const schedule = await scheduleOf(booking.id);
|
||||||
const summary = await chargeDueInstallments(db, gateway, runCtx);
|
for (let i = 0; i < 2; i++) {
|
||||||
expect(summary.charged).toBe(2);
|
const invoice = gateway.billNextInstallment(schedule.id);
|
||||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||||
|
}
|
||||||
|
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||||
expect(after.paymentStatus).toBe("PAID");
|
expect(after.paymentStatus).toBe("PAID");
|
||||||
expect(after.paidCents).toBe(220_000);
|
expect(after.paidCents).toBe(220_000);
|
||||||
|
expect(after.installments.map((i) => i.status)).toEqual(["PAID", "PAID", "PAID"]);
|
||||||
|
expect(gateway.billedCents).toBe(140_000);
|
||||||
|
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
|
||||||
|
|
||||||
await detachFinishedPaymentMethods(db, gateway);
|
await detachFinishedPaymentMethods(db, gateway);
|
||||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true } });
|
||||||
expect(after.paymentMethodDetachedAt).not.toBeNull();
|
expect(after.paymentMethodDetachedAt).not.toBeNull();
|
||||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||||
|
|
||||||
// Weitere Laeufe buchen nichts mehr ab.
|
|
||||||
await chargeDueInstallments(db, gateway, runCtx);
|
|
||||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 30 * 86_400_000) });
|
|
||||||
expect(gateway.offSessionCharges).toHaveLength(2);
|
|
||||||
expect(gateway.offSessionCharges.reduce((s, c) => s + c.amountCents, 0)).toBe(140_000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nur faellige Raten werden eingezogen", async () => {
|
test("doppelt zugestelltes invoice.paid wird nur einmal verbucht", async () => {
|
||||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
|
||||||
const between = { ...ctx, now: new Date(booking.installments[1]!.dueAt!.getTime() + 60_000) };
|
|
||||||
expect((await chargeDueInstallments(db, gateway, between)).charged).toBe(1);
|
|
||||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
|
||||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
|
||||||
expect(after.paidCents).toBe(150_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Fehlschlag: Wiederholung nach Wartezeit, nach dem letzten Versuch manuelle Pruefung", async () => {
|
|
||||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||||
gateway.offSessionOutcomes = ["failed", "failed", "failed"];
|
const invoice = gateway.billNextInstallment((await scheduleOf(booking.id)).id);
|
||||||
let runCtx = afterLastDue(booking);
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||||
for (let attempt = 1; attempt <= MAX_OFF_SESSION_ATTEMPTS; attempt++) {
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||||
await chargeDueInstallments(db, gateway, runCtx);
|
|
||||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
|
||||||
expect(inst.status).toBe("FAILED");
|
|
||||||
expect(inst.attemptCount).toBe(attempt);
|
|
||||||
// Vor Ablauf der Wartezeit kein erneuter Versuch.
|
|
||||||
await chargeDueInstallments(db, gateway, runCtx);
|
|
||||||
expect((await db.installment.findFirstOrThrow({ where: { id: inst.id } })).attemptCount).toBe(attempt);
|
|
||||||
runCtx = { ...runCtx, now: new Date(runCtx.now.getTime() + 4 * 86_400_000) };
|
|
||||||
}
|
|
||||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
|
||||||
expect(after.status).toBe("NEEDS_REVIEW");
|
|
||||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
|
||||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Bank verlangt Authentifizierung: Kundin bekommt Link und zahlt selbst", async () => {
|
|
||||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
|
||||||
gateway.offSessionOutcomes = ["requires_action"];
|
|
||||||
const runCtx = afterLastDue(booking);
|
|
||||||
await chargeDueInstallments(db, gateway, runCtx);
|
|
||||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
|
||||||
expect(inst.status).toBe("REQUIRES_ACTION");
|
|
||||||
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${inst.id}` } } })).toBe(1);
|
|
||||||
|
|
||||||
const pay = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: inst.id }, runCtx);
|
|
||||||
expect(pay.ok).toBe(true);
|
|
||||||
// Waehrend die Session offen ist, zieht der Worker nicht parallel ein.
|
|
||||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 60_000) });
|
|
||||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
|
||||||
|
|
||||||
const sessionId = (await db.installment.findUniqueOrThrow({ where: { id: inst.id } })).checkoutSessionId!;
|
|
||||||
const session = gateway.completeSession(sessionId);
|
|
||||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), runCtx);
|
|
||||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
|
||||||
expect(after.paymentStatus).toBe("PAID");
|
|
||||||
expect(after.paidCents).toBe(220_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Webhook und Worker melden denselben Erfolg: nur einmal verbucht", async () => {
|
|
||||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
|
||||||
const runCtx = afterLastDue(booking);
|
|
||||||
await chargeDueInstallments(db, gateway, runCtx);
|
|
||||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
|
||||||
const pi = await gateway.retrievePaymentIntent(inst.stripePaymentIntentId!);
|
|
||||||
const event = {
|
|
||||||
id: "evt_pi_ok",
|
|
||||||
type: "payment_intent.succeeded",
|
|
||||||
livemode: false,
|
|
||||||
data: {
|
|
||||||
object: {
|
|
||||||
id: pi.id,
|
|
||||||
object: "payment_intent",
|
|
||||||
amount_received: pi.amountReceived,
|
|
||||||
latest_charge: pi.chargeId,
|
|
||||||
payment_method: pi.paymentMethodId,
|
|
||||||
customer: null,
|
|
||||||
metadata: pi.metadata,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as Stripe.Event;
|
|
||||||
await handleStripeEvent(db, gateway, event, runCtx);
|
|
||||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||||
expect(after.paidCents).toBe(220_000);
|
expect(after.paidCents).toBe(220_000);
|
||||||
expect(after.status).toBe("CONFIRMED");
|
expect(after.status).toBe("CONFIRMED");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("verlorener Rechnungs-Webhook: Worker gleicht mit Stripe ab", async () => {
|
||||||
|
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||||
|
gateway.billNextInstallment((await scheduleOf(booking.id)).id);
|
||||||
|
const second = await installment(booking.id, 2);
|
||||||
|
// Vor Faelligkeit wird nicht gefragt.
|
||||||
|
expect((await reconcileInstallmentInvoices(db, gateway, ctx)).checked).toBe(0);
|
||||||
|
const later = { ...ctx, now: new Date(second.dueAt!.getTime() + 7 * 60 * 60_000) };
|
||||||
|
expect((await reconcileInstallmentInvoices(db, gateway, later)).settled).toBe(1);
|
||||||
|
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||||
|
expect(after.paymentStatus).toBe("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fehlschlag: Stripe wiederholt, Kundin wird informiert, nach dem letzten Versuch Pruefung", async () => {
|
||||||
|
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||||
|
const failed = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "failed");
|
||||||
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
|
||||||
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
|
||||||
|
let second = await installment(booking.id, 2);
|
||||||
|
expect(second.status).toBe("FAILED");
|
||||||
|
expect(second.stripeInvoiceId).toBe(failed.id);
|
||||||
|
expect(second.nextAttemptAt).not.toBeNull();
|
||||||
|
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${second.id}` } } })).toBe(1);
|
||||||
|
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("CONFIRMED");
|
||||||
|
|
||||||
|
const final = gateway.retryInvoice(failed.id, "failed", { finalAttempt: true });
|
||||||
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", final), ctx);
|
||||||
|
second = await installment(booking.id, 2);
|
||||||
|
expect(second.attemptCount).toBe(2);
|
||||||
|
expect(second.nextAttemptAt).toBeNull();
|
||||||
|
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||||
|
expect(after.status).toBe("NEEDS_REVIEW");
|
||||||
|
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bank verlangt Bestaetigung: Kundin zahlt ueber Stripes Rechnungsseite", async () => {
|
||||||
|
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||||
|
const open = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "requires_action");
|
||||||
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_action_required", open), ctx);
|
||||||
|
const second = await installment(booking.id, 2);
|
||||||
|
expect(second.status).toBe("REQUIRES_ACTION");
|
||||||
|
|
||||||
|
const pay = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id });
|
||||||
|
expect(pay).toEqual({ ok: true, url: `https://invoice.stripe.test/${open.id}` });
|
||||||
|
|
||||||
|
const paid = gateway.retryInvoice(open.id, "paid");
|
||||||
|
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", paid), ctx);
|
||||||
|
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||||
|
expect(after.paymentStatus).toBe("PAID");
|
||||||
|
expect(after.paidCents).toBe(220_000);
|
||||||
|
// Bezahlte Rate hat keinen Zahlungslink mehr.
|
||||||
|
expect((await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id })).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Rueckbuchung: Stripe zieht bis zur Entscheidung nichts ein, nach Bestaetigung neuer Plan", async () => {
|
||||||
|
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||||
|
const first = await scheduleOf(booking.id);
|
||||||
|
const dispute = {
|
||||||
|
id: `evt_${crypto.randomUUID()}`,
|
||||||
|
type: "charge.dispute.created",
|
||||||
|
livemode: false,
|
||||||
|
data: { object: { id: "dp_1", object: "dispute", payment_intent: booking.installments[0]!.stripePaymentIntentId } },
|
||||||
|
} as unknown as Stripe.Event;
|
||||||
|
await handleStripeEvent(db, gateway, dispute, ctx);
|
||||||
|
|
||||||
|
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||||
|
expect(after.status).toBe("NEEDS_REVIEW");
|
||||||
|
expect(after.stripeScheduleId).toBeNull();
|
||||||
|
expect(first.status).toBe("canceled");
|
||||||
|
expect(after.installments.map((i) => i.status)).toEqual(["PAID", "PENDING", "PENDING"]);
|
||||||
|
// Waehrend der Pruefung legt der Worker keinen neuen Plan an.
|
||||||
|
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(0);
|
||||||
|
|
||||||
|
expect((await confirmReviewedBooking(db, booking.id, { type: "ADMIN", id: "admin-test" }, ctx.now)).ok).toBe(true);
|
||||||
|
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
|
||||||
|
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true } });
|
||||||
|
const second = gateway.schedules.get(after.stripeScheduleId!)!;
|
||||||
|
expect(second.id).not.toBe(first.id);
|
||||||
|
expect(second.params).toMatchObject({ amountCents: 70_000, count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ID des Plans ging verloren: kein zweiter Plan bei Stripe", async () => {
|
||||||
|
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||||
|
await db.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: null } });
|
||||||
|
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
|
||||||
|
expect(gateway.schedules.size).toBe(1);
|
||||||
|
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||||
|
expect(after.stripeScheduleId).toBe([...gateway.schedules.keys()][0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Faelligkeit schon vorbei: Start am naechsten 9-Uhr-Termin, letzte Rate bleibt am Stichtag", async () => {
|
||||||
|
gateway.failNextSchedule = true;
|
||||||
|
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||||
|
const [, second, third] = booking.installments;
|
||||||
|
const late = { ...ctx, now: new Date(second!.dueAt!.getTime() + 3 * 86_400_000 + 2 * 60 * 60_000) };
|
||||||
|
expect((await createMissingSchedules(db, gateway, late)).created).toBe(1);
|
||||||
|
const schedule = [...gateway.schedules.values()][0]!;
|
||||||
|
expect(schedule.params.startAt.getTime()).toBeGreaterThan(late.now.getTime());
|
||||||
|
expect(schedule.params.startAt.getTime() - late.now.getTime()).toBeLessThanOrEqual(86_400_000);
|
||||||
|
const end = schedule.params.startAt.getTime() + schedule.params.intervalDays * 86_400_000;
|
||||||
|
expect(Math.abs(end - third!.dueAt!.getTime())).toBeLessThanOrEqual(60 * 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ratenplan-Anlage scheitert: Zahlung bleibt verbucht, Worker holt die Anlage nach", async () => {
|
||||||
|
gateway.failNextSchedule = true;
|
||||||
|
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||||
|
expect(booking.status).toBe("CONFIRMED");
|
||||||
|
expect(booking.stripeScheduleId).toBeNull();
|
||||||
|
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
|
||||||
|
expect(gateway.schedules.size).toBe(1);
|
||||||
|
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("Erstattung aktualisiert Zahlungsstatus absolut (ungeordnete Events harmlos)", async () => {
|
test("Erstattung aktualisiert Zahlungsstatus absolut (ungeordnete Events harmlos)", async () => {
|
||||||
const booking = await paidBooking("FULL");
|
const booking = await paidBooking("FULL");
|
||||||
const pi = booking.installments[0]!.stripePaymentIntentId!;
|
const pi = booking.installments[0]!.stripePaymentIntentId!;
|
||||||
|
|||||||
@@ -73,8 +73,19 @@ describe("Verkaufsfreigabe", () => {
|
|||||||
expect(evaluateSales(earlyBird, released, "live").checkoutOpen).toBe(true);
|
expect(evaluateSales(earlyBird, released, "live").checkoutOpen).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Livemodus vor dem 01.11.2026: Vorschau ohne Kaufmoeglichkeit, Override wirkungslos", () => {
|
test("Livemodus vor dem 01.11.2026 mit allen Freigaben: offen zum Early-Bird-Preis", () => {
|
||||||
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), { ...released, testPhaseOverride: "EARLY_BIRD" }, "live");
|
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), released, "live");
|
||||||
|
expect(state.checkoutOpen).toBe(true);
|
||||||
|
expect(state.effectivePhase).toBe("EARLY_BIRD");
|
||||||
|
expect(state.phaseSimulated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Livemodus vor dem 01.11.2026 mit fehlender Freigabe: Vorschau, Override wirkungslos", () => {
|
||||||
|
const state = evaluateSales(
|
||||||
|
new Date("2026-10-01T12:00:00Z"),
|
||||||
|
{ ...released, travelLawChecked: false, testPhaseOverride: "REGULAR" },
|
||||||
|
"live",
|
||||||
|
);
|
||||||
expect(state.checkoutOpen).toBe(false);
|
expect(state.checkoutOpen).toBe(false);
|
||||||
expect(state.closedReason).toBe("PREVIEW");
|
expect(state.closedReason).toBe("PREVIEW");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
import type {
|
import type {
|
||||||
CheckoutSessionInfo,
|
CheckoutSessionInfo,
|
||||||
CreateCheckoutParams,
|
CreateCheckoutParams,
|
||||||
OffSessionResult,
|
CreateInstallmentScheduleParams,
|
||||||
|
InvoiceInfo,
|
||||||
PaymentGateway,
|
PaymentGateway,
|
||||||
PaymentIntentInfo,
|
PaymentIntentInfo,
|
||||||
} from "@/lib/payment-gateway";
|
} from "@/lib/payment-gateway";
|
||||||
import type { StripeMode } from "@/lib/sales-gate";
|
import type { StripeMode } from "@/lib/sales-gate";
|
||||||
|
|
||||||
type StoredSession = CheckoutSessionInfo & { params: CreateCheckoutParams };
|
type StoredSession = CheckoutSessionInfo & { params: CreateCheckoutParams };
|
||||||
|
type StoredSchedule = { id: string; status: "active" | "canceled"; params: CreateInstallmentScheduleParams; invoiceIds: string[] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stripe-Attrappe im Speicher. Bildet die fuer die Geschaeftslogik
|
* Stripe-Attrappe im Speicher. Bildet die fuer die Geschaeftslogik
|
||||||
* relevanten Eigenschaften nach: Idempotenzschluessel, Session-Zustaende,
|
* relevanten Eigenschaften nach: Idempotenzschluessel, Session-Zustaende,
|
||||||
* PaymentIntents und das Ergebnis von Off-Session-Einzuegen.
|
* PaymentIntents und die Rechnungen eines Stripe-Billing-Ratenplans.
|
||||||
*/
|
*/
|
||||||
export class FakeGateway implements PaymentGateway {
|
export class FakeGateway implements PaymentGateway {
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
private readonly byKey = new Map<string, unknown>();
|
private readonly byKey = new Map<string, unknown>();
|
||||||
readonly sessions = new Map<string, StoredSession>();
|
readonly sessions = new Map<string, StoredSession>();
|
||||||
readonly paymentIntents = new Map<string, PaymentIntentInfo>();
|
readonly paymentIntents = new Map<string, PaymentIntentInfo>();
|
||||||
readonly offSessionCharges: { key: string; amountCents: number; installmentId: string }[] = [];
|
readonly schedules = new Map<string, StoredSchedule>();
|
||||||
|
readonly invoices = new Map<string, InvoiceInfo>();
|
||||||
readonly detached: string[] = [];
|
readonly detached: string[] = [];
|
||||||
readonly refunds: { id: string; paymentIntentId: string; amountCents: number }[] = [];
|
readonly refunds: { id: string; paymentIntentId: string; amountCents: number }[] = [];
|
||||||
/** Ergebnisse fuer die naechsten Off-Session-Einzuege (Standard: Erfolg). */
|
|
||||||
offSessionOutcomes: OffSessionResult["outcome"][] = [];
|
|
||||||
failNextCheckout = false;
|
failNextCheckout = false;
|
||||||
|
failNextSchedule = false;
|
||||||
|
|
||||||
constructor(readonly mode: StripeMode = "test") {}
|
constructor(readonly mode: StripeMode = "test") {}
|
||||||
|
|
||||||
@@ -101,34 +103,101 @@ export class FakeGateway implements PaymentGateway {
|
|||||||
return { ...pi };
|
return { ...pi };
|
||||||
}
|
}
|
||||||
|
|
||||||
async chargeOffSession(
|
async createInstallmentSchedule(params: CreateInstallmentScheduleParams, key: string) {
|
||||||
params: { customerId: string; paymentMethodId: string; amountCents: number; metadata: Record<string, string> },
|
if (this.failNextSchedule) {
|
||||||
key: string,
|
this.failNextSchedule = false;
|
||||||
): Promise<OffSessionResult> {
|
throw new Error("Stripe nicht erreichbar (simuliert)");
|
||||||
return this.once(`pi:${key}`, () => {
|
}
|
||||||
const outcome = this.offSessionOutcomes.shift() ?? "succeeded";
|
return this.once(`sub_sched:${key}`, () => {
|
||||||
const piId = this.id("pi");
|
const id = this.id("sub_sched");
|
||||||
const chargeId = outcome === "succeeded" ? this.id("ch") : null;
|
this.schedules.set(id, { id, status: "active", params, invoiceIds: [] });
|
||||||
this.paymentIntents.set(piId, {
|
return { id };
|
||||||
id: piId,
|
|
||||||
status: outcome === "succeeded" ? "succeeded" : outcome === "processing" ? "processing" : outcome === "requires_action" ? "requires_action" : "requires_payment_method",
|
|
||||||
amountReceived: outcome === "succeeded" ? params.amountCents : 0,
|
|
||||||
paymentMethodId: params.paymentMethodId,
|
|
||||||
chargeId,
|
|
||||||
metadata: params.metadata,
|
|
||||||
});
|
|
||||||
if (outcome === "succeeded") {
|
|
||||||
this.offSessionCharges.push({ key, amountCents: params.amountCents, installmentId: params.metadata.installmentId! });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
outcome,
|
|
||||||
paymentIntentId: piId,
|
|
||||||
chargeId,
|
|
||||||
failureCode: outcome === "failed" ? "card_declined" : outcome === "requires_action" ? "authentication_required" : null,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null> {
|
||||||
|
const found = [...this.schedules.values()].find(
|
||||||
|
(s) => s.status === "active" && s.params.customerId === customerId && s.params.metadata.bookingId === bookingId,
|
||||||
|
);
|
||||||
|
return found ? { id: found.id } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelInstallmentSchedule(id: string): Promise<void> {
|
||||||
|
const schedule = this.schedules.get(id);
|
||||||
|
if (!schedule) throw new Error(`Unbekannter Zeitplan ${id}`);
|
||||||
|
schedule.status = "canceled";
|
||||||
|
for (const invoiceId of schedule.invoiceIds) {
|
||||||
|
const invoice = this.invoices.get(invoiceId)!;
|
||||||
|
if (invoice.status === "open") this.invoices.set(invoiceId, { ...invoice, status: "void", hostedInvoiceUrl: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveInvoice(id: string): Promise<InvoiceInfo> {
|
||||||
|
const invoice = this.invoices.get(id);
|
||||||
|
if (!invoice) throw new Error(`Unbekannte Rechnung ${id}`);
|
||||||
|
return { ...invoice };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]> {
|
||||||
|
return (this.schedules.get(scheduleId)?.invoiceIds ?? []).map((id) => ({ ...this.invoices.get(id)! }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simuliert den naechsten Einzug durch Stripe Billing: erzeugt die
|
||||||
|
* naechste Rechnung des Zeitplans, bezahlt oder offen. Ein aktiver Zeitplan
|
||||||
|
* erzeugt nie mehr Rechnungen als Folgeraten.
|
||||||
|
*/
|
||||||
|
billNextInstallment(scheduleId: string, outcome: "paid" | "failed" | "requires_action" = "paid", opts: { finalAttempt?: boolean } = {}): InvoiceInfo {
|
||||||
|
const schedule = this.schedules.get(scheduleId);
|
||||||
|
if (!schedule || schedule.status !== "active") throw new Error(`Zeitplan ${scheduleId} ist nicht aktiv`);
|
||||||
|
if (schedule.invoiceIds.length >= schedule.params.count) throw new Error("Zeitplan hat bereits alle Raten abgerechnet");
|
||||||
|
const id = this.id("in");
|
||||||
|
schedule.invoiceIds.push(id);
|
||||||
|
const invoice = this.invoiceResult(id, schedule, outcome, 1, opts.finalAttempt ?? false);
|
||||||
|
this.invoices.set(id, invoice);
|
||||||
|
return { ...invoice };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simuliert einen weiteren Versuch (Smart Retry oder Zahlung ueber die Rechnungsseite). */
|
||||||
|
retryInvoice(invoiceId: string, outcome: "paid" | "failed" | "requires_action", opts: { finalAttempt?: boolean } = {}): InvoiceInfo {
|
||||||
|
const current = this.invoices.get(invoiceId)!;
|
||||||
|
if (current.status !== "open") throw new Error(`Rechnung ${invoiceId} ist nicht offen`);
|
||||||
|
const schedule = [...this.schedules.values()].find((s) => s.invoiceIds.includes(invoiceId))!;
|
||||||
|
const invoice = this.invoiceResult(invoiceId, schedule, outcome, current.attemptCount + 1, opts.finalAttempt ?? false);
|
||||||
|
this.invoices.set(invoiceId, invoice);
|
||||||
|
return { ...invoice };
|
||||||
|
}
|
||||||
|
|
||||||
|
private invoiceResult(id: string, schedule: StoredSchedule, outcome: "paid" | "failed" | "requires_action", attempt: number, finalAttempt: boolean): InvoiceInfo {
|
||||||
|
let paymentIntentId: string | null = null;
|
||||||
|
if (outcome === "paid") {
|
||||||
|
paymentIntentId = this.id("pi");
|
||||||
|
this.paymentIntents.set(paymentIntentId, {
|
||||||
|
id: paymentIntentId,
|
||||||
|
status: "succeeded",
|
||||||
|
amountReceived: schedule.params.amountCents,
|
||||||
|
paymentMethodId: schedule.params.paymentMethodId,
|
||||||
|
chargeId: this.id("ch"),
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status: outcome === "paid" ? "paid" : "open",
|
||||||
|
amountPaidCents: outcome === "paid" ? schedule.params.amountCents : 0,
|
||||||
|
paymentIntentId,
|
||||||
|
hostedInvoiceUrl: outcome === "paid" ? null : `https://invoice.stripe.test/${id}`,
|
||||||
|
attemptCount: attempt,
|
||||||
|
nextPaymentAttempt: outcome === "failed" && !finalAttempt ? new Date(Date.UTC(2027, 0, 1) + attempt * 86_400_000) : null,
|
||||||
|
metadata: schedule.params.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summe aller ueber den Zeitplan eingezogenen Betraege. */
|
||||||
|
get billedCents(): number {
|
||||||
|
return [...this.invoices.values()].reduce((sum, i) => sum + i.amountPaidCents, 0);
|
||||||
|
}
|
||||||
|
|
||||||
async detachPaymentMethod(id: string): Promise<void> {
|
async detachPaymentMethod(id: string): Promise<void> {
|
||||||
this.detached.push(id);
|
this.detached.push(id);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user