Compare commits

..
10 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 cbc81caa1a fix: Turnstile-Widget erschien nur beim ersten Formular je Seitenaufbau
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m59s
Symptom: Das CAPTCHA fehlte gelegentlich und war erst nach einem
Neuladen der Seite da.

Ursache ist kein Wettlauf, sondern ein deterministischer Ausfall.
next/script fuehrt onLoad fuer eine bereits geladene Quelle NICHT erneut
aus — der Schluessel ist dabei die URL (id || src) und damit fuer alle
Widgets derselbe. In next/dist/client/script.js:

    const cacheKey = id || src;
    // Script has already loaded
    if (cacheKey && LoadCache.has(cacheKey)) {
      return;
    }

Das Widget wartete aber genau auf dieses onLoad, bevor es
turnstile.render() aufrief. Also bekam nur das ERSTE Widget nach einem
echten Seitenaufbau eines; jedes weitere blieb leer:

  - die Kontoseite zeigt Anmeldung UND Registrierung untereinander, also
    zwei Widgets — eines davon war immer tot;
  - jedes Formular, das ueber einen Link im Seitenverlauf erreicht wurde
    statt ueber F5.

Weil die Newsletter-Anmeldung im Footer steht, laedt das Skript auf
praktisch jeder Seite. Damit fuehrte fast jeder Weg durch die Seite in
den kaputten Zustand — und ein Neuladen half, weil das betroffene Widget
dann wieder als erstes an der Reihe war. Das erklaert das "manchmal".

Folgen waren groesser, als es aussieht: ohne Widget kein Token, ohne
Token weist der Server das Formular ab. Anmeldung, Registrierung,
Passwort-Reset, Zwei-Faktor-Eingabe und Checkout waren blockiert, ohne
dass irgendwo eine Fehlermeldung stand.

Das Widget laedt das Skript jetzt selbst, ueber ein von allen Instanzen
geteiltes Versprechen, und haengt an keinem Ereignis mehr, das man
verpassen kann: es zaehlt allein, ob window.turnstile vorhanden ist.
Dazu drei Dinge, die vorher fehlten:

  - "render=explicit", damit Turnstile nicht zusaetzlich selbst nach
    Containern sucht;
  - turnstile.remove() beim Verlassen, statt das Widget stehen zu lassen;
  - ein SICHTBARER Hinweis mit "Erneut versuchen", wenn das Skript nicht
    laedt (Netzaussetzer, Inhaltsblocker). Vorher blieb dort eine leere
    Flaeche, und das Formular liess sich ohne erkennbaren Grund nicht
    abschicken.

Der Callback liegt in einem Ref: eine bei jedem Render neu erzeugte
Funktion haette das Widget sonst abgebaut und mit ihm ein bereits
geloestes CAPTCHA.

Fuenf Regressionstests (src/components/turnstile-widget.test.tsx), allen
voran "baut sich auch dann auf, wenn das Skript schon geladen ist". Per
Mutationstest geprueft, dass sie ohne den Fix rot werden.

tsc: 0 Fehler. vitest: 333/333 gruen (zuvor 328). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 05:46:14 +02:00
Nick SzaunigandClaude Opus 5 e5cae9b49d perf: Firmendaten und Social-Links je Request nur noch einmal laden
getCompanyInfo() wurde bei JEDEM Seitenaufruf mehrfach ausgefuehrt: das
Root-Layout braucht es zweimal (einmal in generateMetadata, einmal fuer
das LocalBusiness-Schema im Rumpf), dazu kommt fast jede Seite mit einem
eigenen Aufruf. Jeder davon war eine eigene Datenbankabfrage ueber
dieselben zehn Schluessel — dreimal dasselbe Ergebnis pro gerenderter
Seite, auf jeder Seite des Shops.

Beide Funktionen sind jetzt in React-cache() gehuellt. Die
Deduplizierung gilt je Request und ist KEIN serverweiter
Zwischenspeicher: eine Aenderung im Admin-Bereich wirkt unveraendert
sofort, es gibt nichts zu invalidieren. Beide Funktionen nehmen keine
Argumente, damit greift die Deduplizierung zuverlaessig — cache()
unterscheidet Aufrufe anhand ihrer Argumente.

Geprueft: "next build" laeuft durch, tsc 0 Fehler, vitest 328/328 gruen,
eslint 0 Warnungen.

Nicht geaendert, aber geprueft: die Admin-Listen ohne "take" (Chargen,
Ernten, Gutscheine, Inhalte, Produkte, Versandstaffeln) betreffen
ausnahmslos kleine, langsam wachsende Tabellen — eine Begrenzung waere
dort Vorratsarbeit. Die Listen auf den wachsenden Tabellen (Bestellungen,
Bewertungen, Audit-Log, Retouren, Anfragen, Benutzer) sind bereits
begrenzt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 04:47:43 +02:00
Nick SzaunigandClaude Opus 5 2bbb38c31a fix: SSO umging die Admin-2FA; perf: Dashboard und fehlende Indizes
SICHERHEIT — "Mit Microsoft anmelden" uebersprang die eigene 2FA auch bei
Admin-Konten.

requireAdmin (admin/actions/_shared.ts) laesst eine Admin-Aktion nur zu,
wenn am Konto twoFactorEnabled steht. Das Flag soll zusichern, dass hinter
jeder Rueckerstattung und jedem Blick in die Kundendaten tatsaechlich ein
zweiter Faktor stand. Der SSO-Rueckweg legte aber direkt eine
vollstaendige Session an und lieferte damit ein Flag, das "geprueft"
behauptet, obwohl in diesem Anmeldevorgang nichts geprueft wurde — die
Zusicherung hing allein an einer Annahme ueber die MFA-Richtlinie des
Entra-Mandanten, die dieser Code nicht nachpruefen kann.

Ein ADMIN mit aktivierter 2FA laeuft jetzt auch per SSO durch denselben
TOTP-Schritt wie beim Passwort-Login. Ausgesperrt wird dabei niemand:
twoFactorEnabled heisst, dass der zweite Faktor eingerichtet ist. Fuer
KUNDENkonten bleibt der bequeme Weg wie dokumentiert bestehen.

PERFORMANCE — zwei unbegrenzte Abfragen auf dem Admin-Dashboard.

Die Startseite des Admin-Bereichs lud bei jedem Aufruf

  1. JEDE Bestellposition, die es je gab, samt Produkt-Join — um daraus
     fuenf Zeilen "Top-Produkte" zu rechnen, und
  2. JEDE Bestellung, die es je gab — um daraus eine einzige Zahl
     ("Stammkunden") zu machen. Die Begruendung im Code lautete, Prisma
     koenne kein HAVING. Es kann.

Beides erledigt jetzt die Datenbank per groupBy. Aus "waechst mit jedem
Verkauf" wird "eine Zeile je verkauftem Produkt" bzw. "eine Zeile je
Stammkundschaft". Die Kategorie haengt am Produkt und laesst sich nicht
mitgruppieren; sie kommt aus einer kurzen Nachfrage ueber die
Primaerschluessel der tatsaechlich verkauften Produkte.

PERFORMANCE — fehlende Indizes auf order_items.

PostgreSQL legt fuer Fremdschluessel-Spalten keinen Index an (anders als
MySQL/InnoDB). Auf der am schnellsten wachsenden Tabelle des Shops fehlten
dadurch zwei:

  - productId: hasCustomerPurchased (lib/reviews.ts) prueft den
    "verifizierten Kauf" bei JEDEM Aufruf einer Produktseite durch eine
    angemeldete Kundschaft — bisher als vollstaendiger Tabellenscan.
  - batchId: das Loeschen einer Charge prueft ueber onDelete: Restrict
    gegen dieselbe Tabelle.

Migration 20260919010000_order_item_fk_indexes.

tsc: 0 Fehler. vitest: 328/328 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 04:22:53 +02:00
Nick SzaunigandClaude Opus 5 3e81896194 fix: Session-Pruefung akzeptierte Token anderer Zwecke
Alle Token in lib/session.ts sind mit DEMSELBEN Geheimnis signiert und
tragen denselben Aussteller und dieselbe Zielgruppe. Unterschieden werden
sie allein ueber ein "type"-Feld — und genau das prueft
verifySessionToken nicht. Es verlangte nur eine vorhandene "sub".

Damit bestand ein 2FA-Challenge-Token die Session-Pruefung und galt als
vollwertige Anmeldung. Dieses Cookie wird ausgestellt, NACHDEM das
Passwort stimmt, aber BEVOR der zweite Faktor geprueft ist: wer es im
Browser in das Session-Cookie kopiert, umgeht die Zwei-Faktor-Pruefung.
Praktisch nachgestellt:

  signTwoFactorChallenge("customer_123")
  -> verifySessionToken(...) ergab
     { customerId: "customer_123", role: "KUNDE", sessionVersion: 0 }

Dasselbe galt fuer den 2FA-Einrichtungstoken und den Token der
Backup-Code-Anzeige. Der Entra-OAuth-State trug keine "sub" und fiel
schon vorher durch.

Aufgehalten wurde das bisher nur zufaellig: diese Token fuehren keine
sessionVersion (also 0), und getCurrentCustomer vergleicht sie gegen die
Datenbank — wer 2FA aktiviert, steht dort bereits bei mindestens 1
(konto/actions/two-factor.ts zaehlt hoch). Das ist eine Sperre, die aus
einem voellig anderen Grund existiert und lautlos wegfaellt, sobald sich
an Zaehler oder Token etwas aendert.

verifySessionToken lehnt jetzt jeden Token mit "type"-Feld ab. Geprueft
wird auf ABWESENHEIT, nicht auf einen bestimmten Wert: das Session-Token
setzt selbst kein "type", bestehende Cookies bleiben also gueltig und
niemand wird durch das Ausrollen abgemeldet.

Dazu die erste Testabdeckung fuer die Token-Schicht ueberhaupt
(lib/session.test.ts, 12 Faelle): Rundlauf, manipulierter Token,
unbekannte Rolle wird auf KUNDE heruntergestuft, und jede Kombination von
Token-Verwechslung in beide Richtungen. Die Datei laeuft bewusst in der
node-Umgebung — jose prueft mit "instanceof Uint8Array", und jsdoms
TextEncoder liefert ein Uint8Array aus einem anderen Realm.

Nebenbei in honig/actions.ts: toggleWishlistAction nahm die productId
ungeprueft aus dem Formular. Ein erfundener Wert lief in eine
Fremdschluesselverletzung und damit in die allgemeine Fehlerseite — ein
500er als Antwort auf eine schlicht ungueltige Eingabe. Jetzt wird die
Existenz geprueft, und ein deaktiviertes Produkt kommt nicht neu auf eine
Wunschliste.

tsc: 0 Fehler. vitest: 328/328 gruen (zuvor 316). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 02:30:47 +02:00
Nick SzaunigandClaude Opus 5 5170e477bc fix: leere Telefonnummer wird nirgends mehr angezeigt
Ohne im Admin-Bereich gepflegte Nummer griff bisher der Rueckfallwert aus
lib/site-config.ts — und der war eine Platzhalternummer
("+49 (0) 000 000000"). Sie stand damit tatsaechlich auf der Kontaktseite
und im Impressum. Eine Nummer, unter der niemand erreichbar ist, ist
schlimmer als gar keine Angabe.

Der Rueckfallwert ist jetzt leer, und beide Stellen lassen die Zeile bei
leerem Feld weg:

- kontakt/page.tsx rendert das Absatz-Element gar nicht erst.
- impressum/page.tsx nimmt die Telefonzeile aus dem Kontakt-Abschnitt.
  § 5 TMG verlangt eine Moeglichkeit zur unmittelbaren Kontaktaufnahme,
  nicht zwingend ein Telefon — die E-Mail-Adresse erfuellt das.

Der DHL-Versand war bereits darauf vorbereitet: buildDhlContact setzt
"phone: address.phone || undefined" und laesst das Feld im Aufruf
entsprechend weg (lib/dhl.ts).

Die Go-Live-Pruefung warnt weiterhin bei einer EINGETRAGENEN
Platzhalternummer, aber nicht mehr bei einem leeren Feld — leer ist ab
sofort eine zulaessige Entscheidung, und der Hinweistext sagt das.

tsc: 0 Fehler. vitest: 316/316 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 20:54:54 +02:00
Nick SzaunigandClaude Opus 5 dbf146c19f test: Geldpfade absichern und Go-Live-Pruefung ehrlich machen
Die beiden Dateien, in denen Geld und Lagerbestand zusammenlaufen, hatten
bisher keinen einzigen Test. Dazu meldete die Go-Live-Pruefung gruene
Haken fuer Werte, die gar nicht gesetzt waren.

Tests fuer api/checkout/route.ts (10 Faelle)
  Torwaechter (fremder Origin, Turnstile, Rate-Limit), deaktiviertes
  Produkt, Preis 0, ausverkaufte Ware, Sitzungslebensdauer — und die
  beiden Regressionen: scheitert die Stripe-Sitzung, wird die Bestellung
  aufgeloest und OHNE orderNumber geantwortet; laesst sich die
  Sitzungs-ID nicht speichern, wird die Sitzung geschlossen. Per
  Mutationstest geprueft, dass der zweite Fall ohne den Fix rot wird.

Tests fuer api/checkout/webhook/route.ts (12 Faelle)
  Signaturpruefung, verzoegerte Zahlung (SEPA), Auslieferung samt
  Rechnung und Anhang, mehrfach zugestelltes Ereignis (Gutschein und
  Rechnungsnummer duerfen sich nicht wiederholen), Zahlung auf eine
  stornierte Bestellung, Betragsabweichung, abgelaufene Sitzung,
  fehlgeschlagener Einzug, volle und teilweise Erstattung, Rueckbuchung.

Datenbank und Stripe sind in beiden Dateien vollstaendig ersetzt.

Go-Live-Pruefung: falsch-positive Haken beseitigt
  Regeln, die einen Wert voraussetzen, bestanden bei fehlendem Wert
  einfach — eine leere Zeichenkette enthaelt nun einmal kein
  "example.com". Der Bericht meldete deshalb "NEXT_PUBLIC_SITE_URL ist
  die echte Domain" als bestanden, direkt neben dem Blocker
  "NEXT_PUBLIC_SITE_URL gesetzt". Ohne jeden Stripe-Schluessel galt
  sogar der Live-Modus als bestaetigt. Das ist die gefaehrlichste
  Fehlmeldung, die eine solche Pruefung machen kann: sie beruhigt ueber
  genau den Punkt, der offen ist. mustWhenSet/shouldWhenSet lassen
  Folgeregeln bei fehlendem Wert entfallen.

  Ausserdem sagt der Bericht jetzt ausdruecklich, dass er die Umgebung
  prueft, in der er LAEUFT. Lokal meldet er Blocker, die in der
  ConfigMap des Clusters laengst erledigt sind; wer das ein paarmal
  erlebt, liest ueber den Bericht hinweg. Das README nennt den Aufruf
  im Pod.

tsc: 0 Fehler. vitest: 316/316 gruen (zuvor 294). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 17:50:38 +02:00
Nick SzaunigandClaude Opus 5 1fa8dc9ce2 fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten
Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.

1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
   Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
   "NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
   Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
   filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
   kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
   fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
   auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
   diese Bestellung je zu bezahlen.

   Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
   Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
   der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
   Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
   geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
   Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
   Altbestaende trotzdem ab (Filter entfernt).

2. Kaufabbrueche standen als Bestellungen im Kundenkonto
   getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
   abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
   Bestellnummer und Summe in der Liste und sah aus wie ein zustande
   gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
   Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
   "Zahlung ausstehend" statt "Neu".

3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
   Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
   Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
   Payment.status auf REFUNDED.

4. Admin-Storno schloss die offene Stripe-Sitzung nicht
   Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
   Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
   jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
   nicht mehr offene Bestellung laut, statt sie zu verschweigen.

5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
   charge.refunded und charge.dispute.created werden jetzt verarbeitet:
   eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
   eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
   Vermerk an der Bestellung. preflight prueft beide Abonnements mit.

Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.

tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 16:57:18 +02:00
Nick SzaunigandClaude Opus 5 dda0a66c6c fix: ein abgebrochener Checkout hinterlaesst keine Bestellung mehr
Bisher blieb nach einem Abbruch eine Bestellung im Status "STORNIERT"
stehen. Im Admin-Bereich sah damit jeder weggeklickte Warenkorb aus wie ein
echter, gescheiterter Verkauf — bei einem Shop, in dem die meisten Besuche
nicht im Kauf enden, verdecken diese Einträge irgendwann die tatsächlichen
Vorgänge.

Ein nie bezahlter Checkout wird deshalb gelöscht statt storniert. Es gab kein
Geschäft, keine Zahlung, keine Bestätigung und keine Rechnung, also auch
nichts aufzubewahren; die Positionen gehen mit (onDelete: Cascade). Betroffen
sind der Abbruch-Knopf, die abgelaufene Stripe-Sitzung und der Aufräumlauf.

Eine Ausnahme bleibt bewusst bestehen: checkout.session.async_payment_failed.
Dort wurde tatsächlich eine Zahlung versucht und ist gescheitert
(SEPA-Lastschrift, teils Klarna) — die Kundschaft hat den Checkout
abgeschlossen und rechnet mit einer Lieferung. Dieser Fall bleibt als
"STORNIERT" nachvollziehbar und wird weiterhin per E-Mail beantwortet. Beim
bloßen Abbruch entfällt die Mail: eine Nachricht über einen Vorgang, den man
selbst abgebrochen hat, verwirrt mehr als sie hilft.

releaseUnpaidOrder unterscheidet die beiden Fälle jetzt über einen Parameter.
Beim Löschen ist deleteMany mit der Statusbedingung der atomare Zugriff — es
entfernt entweder genau diese eine noch unbezahlte Bestellung oder nichts.
Hat inzwischen eine eingehende Zahlung den Status auf "BEZAHLT" gesetzt,
fängt count === 0 das ab: eine bezahlte Bestellung darf dieser Weg unter
keinen Umständen entfernen. Die Positionen werden vor dem Löschen gelesen,
sonst wäre der Bestand danach nicht mehr zurückzubuchen.

Die Reservierungsdauer bleibt bei 32 Minuten. Kürzer geht nicht: Stripe lässt
als Untergrenze 30 Minuten zu (die zwei Minuten sind Puffer gegen
auseinanderlaufende Uhren). Eine kürzere Reservierung ließe sich nur bauen,
indem die Sitzung vorzeitig per API geschlossen wird — das hieße, einer
Kundschaft, die gerade eine 3-D-Secure-Abfrage beantwortet, die Zahlung unter
den Händen wegzunehmen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:13:35 +02:00
Nick SzaunigandClaude Opus 5 a3f91b83fc fix: abgebrochener Checkout gibt die reservierte Ware sofort wieder frei
Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand
anders dasselbe Glas kaufen. Bricht die Kundschaft bei Stripe ab, passierte
danach: nichts. Die Seite /checkout/cancel war reiner Text, die Bestellung
blieb auf "NEU", und der Bestand kam erst zurück, wenn Stripe die Sitzung für
abgelaufen erklärte — nach Voreinstellung 24 Stunden später.

Das ist nicht nur unschön, es ist ausnutzbar. Zehn Checkouts pro Stunde und
IP, bis zu zwanzig Stück je Position, fünfzig Positionen: damit lässt sich der
Shop leerräumen, ohne einen Cent zu zahlen, und das Lager bleibt einen Tag
lang blockiert.

Drei Schichten dagegen, jede für sich wirksam:

1. Die Stripe-Sitzung läuft nach 32 statt 24 Stunden ab (expires_at). Stripe
   lässt als Untergrenze 30 Minuten zu; die zwei Minuten Aufschlag sind Puffer
   gegen auseinanderlaufende Uhren, sonst kann die Erstellung als "weniger als
   30 Minuten" abgewiesen werden.

2. Der Abbruch-Knopf bucht sofort zurück. Die cancel_url trägt dazu einen
   eigenen Token (Order.cancelToken, 32 Byte), nicht die Bestellnummer: die
   steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich
   also die offene Zahlung einer fremden Person abbrechen. Der Token wird
   nach Gebrauch aus der Adresszeile entfernt.

   {CHECKOUT_SESSION_ID} wäre der naheliegende Weg gewesen, ist aber nur für
   success_url und return_url dokumentiert, nicht für cancel_url — darauf
   baut hier nichts.

3. Ein CronJob prüft alle 30 Minuten nach, was liegengeblieben ist. Er greift
   genau dann, wenn Schicht 2 nicht greifen kann: checkout.session.expired
   ist im Dashboard nicht abonniert, der Endpunkt war nicht erreichbar, die
   Zustellversuche sind abgelaufen. Ohne ihn bliebe der Bestand dauerhaft
   blockiert, und zwar unsichtbar — eine solche Bestellung sieht im Admin
   aus wie jede andere offene.

Gefragt wird an jeder Stelle zuerst Stripe, nie die Uhr allein. Ist die
Sitzung dort "complete", war der Aufruf kein Abbruch, sondern ein
Zurück-Klick nach erfolgreicher Zahlung — dann bleibt alles unangetastet.
Umgekehrt vorzugehen hieße, eine bezahlte Bestellung zu stornieren und ihre
Ware ein zweites Mal zu verkaufen.

Findet der Aufräumlauf eine Bestellung, die bei Stripe bezahlt ist, hier aber
noch auf "NEU" steht, rührt er sie nicht an: das nachträglich selbst zu
erledigen hieße, Rechnung, Bestätigungsmail und Gutscheinzählung zu
überspringen. Er meldet den Fall und endet mit Exit-Code 1, damit er als
fehlgeschlagener Job auffällt statt in einer Logzeile zu verschwinden.

Das Stornieren samt Rückbuchung liegt jetzt einmal in lib/order-release.ts
statt verteilt. Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur
wenn noch NEU"), damit Abbruch-Klick und Webhook im selben Moment den Bestand
nicht doppelt gutschreiben.

Weiter in diesem Zug:

- Betragsabgleich im Webhook: weicht session.amount_total von der eigenen
  Summe ab, hat etwas außerhalb dieses Codes mitgerechnet (am ehesten eine im
  Dashboard eingeschaltete automatische Steuer). Die Bestellung bleibt
  bezahlt — das Geld ist geflossen —, aber die Abweichung wird laut geloggt.
  Sonst wird bei zu wenig eingezogenem Geld stillschweigend mit Verlust
  versendet, und bei zu viel weicht die Rechnung vom belasteten Betrag ab.

- preflight prüft, ob der Webhook-Endpunkt alle vier Ereignisse abonniert
  hat. Ein eingeschränkter Schlüssel darf die Liste womöglich nicht lesen;
  dann meldet die Prüfung "ungeprüft" statt "in Ordnung".

- Packstation-Bestellungen waren vollständig kaputt: die Muster für
  Packstation- und Postnummer lauteten /^d{1,10}$/ und /^d{6,10}$/. Ohne
  Backslash trifft das nicht "Ziffer", sondern den Buchstaben d — jede echte
  Nummer wurde abgewiesen, gültig wären nur Eingaben wie "dddddd" gewesen.
  Der Fehler war beim Lesen kaum zu sehen, weil die Fehlermeldung daneben das
  Richtige sagte. Drei Tests halten das jetzt fest.

- Das Laufzeit-Image bekommt src/lib vollständig statt zweier einzelner
  Dateien: der CronJob zieht über order-release.ts weitere Module nach, und
  einzeln aufgezählt fällt so etwas erst im Cluster mit "Cannot find module"
  auf.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:07:22 +02:00
Nick SzaunigandClaude Opus 5 e525db84ef feat: fortlaufende Rechnungsnummer statt Zufallszahl
§ 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Rechnungsnummer. Bisher
stand dort "RE-2026-<8 Hex>": eindeutig ja, fortlaufend nein. Bei einer
Betriebsprüfung ließe sich damit nicht zeigen, dass keine Rechnung fehlt —
aus einer Lücke in einer Zufallsfolge lässt sich nichts ablesen, aus einer
Lücke in 0001, 0002, 0004 sehr wohl. Das war der letzte offene Rechtspunkt
vor dem Live-Gang, und der einzige, der sich ohne Absprache schließen ließ.

Die Nummer heißt jetzt RE-<Jahr>-<laufende Nummer>, also RE-2026-0001. Die
Zählung beginnt jedes Jahr neu bei 0001; das ist zulässig, solange das Jahr
Teil der Nummer ist.

Die laufende Nummer kommt aus der neuen Tabelle invoice_counters, eine Zeile
je Jahr. Der naheliegende Weg "höchste vorhandene Nummer + 1" wäre falsch:
zwei gleichzeitig eintreffende Stripe-Webhooks läsen denselben Höchstwert und
vergäben dieselbe Nummer. Das upsert mit { increment: 1 } sperrt die Zeile,
solange es zählt, und serialisiert damit die Vergabe.

Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Rechnung existiert. Stripe stellt denselben Webhook erneut zu, wenn die
Bestätigung ausbleibt — ohne diese Prüfung verbrauchte jeder Wiederholungs-
versuch eine Nummer und risse genau die Lücke, die hier vermieden werden
soll.

Vier Stellen halten die Nummern gleich lang und damit sortierbar; ab der
zehntausendsten Rechnung eines Jahres wächst die Nummer mit, statt
abgeschnitten zu werden — eine abgeschnittene Nummer wäre nicht mehr
eindeutig.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 01:26:18 +02:00
41 changed files with 2705 additions and 177 deletions

No files matched your search

+7 -2
View File
@@ -72,8 +72,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY prisma.config.ts ./ COPY prisma.config.ts ./
COPY scripts ./scripts COPY scripts ./scripts
COPY src/lib/db-adapter.ts ./src/lib/db-adapter.ts # Das ganze lib-Verzeichnis statt einzelner Dateien: neben prisma/seed.ts
COPY src/lib/password-breach-check.ts ./src/lib/password-breach-check.ts # laeuft im Container auch scripts/release-stale-orders.ts (CronJob, siehe
# k8s/07-backup-cronjob.yaml), das ueber lib/order-release.ts weitere Module
# nachzieht. Einzeln aufgezaehlt faellt so ein Lauf erst im Cluster mit
# "Cannot find module" auf. Die Dateien liegen nur herum — der Next.js-Server
# nutzt ausschliesslich seinen gebuendelten Standalone-Output.
COPY src/lib ./src/lib
COPY --from=builder /app/src/generated ./src/generated COPY --from=builder /app/src/generated ./src/generated
# Migrations-/Seed-Pakete (prisma-CLI/tsx/dotenv) DIREKT ins selbe # Migrations-/Seed-Pakete (prisma-CLI/tsx/dotenv) DIREKT ins selbe
# node_modules wie der Next.js-Standalone-Output kopieren (ueberlagert dort # node_modules wie der Next.js-Standalone-Output kopieren (ueberlagert dort
+63 -5
View File
@@ -157,9 +157,49 @@ SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SE
- `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall). - `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall).
- `checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus). - `checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus).
- `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt. - `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt.
- `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung genauso wie einen fehlgeschlagenen Zahlungseinzug — storniert die Bestellung und bucht den beim Checkout reservierten Bestand zurück, statt ihn dauerhaft zu blockieren. - `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung anders als einen fehlgeschlagenen Einzug: der reservierte Bestand geht zurück ins Regal und die Bestellung wird **gelöscht**, nicht storniert. Es gab keine Zahlung, keine Bestätigung und keine Rechnung — also auch nichts aufzubewahren. Eine stornierte Bestellung stehen zu lassen würde nur verwirren: im Admin-Bereich sähe jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf.
Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. Zwei weitere Events betreffen Geld, das den Shop wieder verlässt, **ohne dass hier jemand etwas angeklickt hat**:
- `charge.refunded` hält eine Erstattung fest, die von Hand im Stripe-Dashboard ausgelöst wurde. Ohne dieses Abonnement stand die Zahlung im Shop weiter auf `SUCCEEDED` und jede Auswertung zählte einen Umsatz mit, den es nicht mehr gab. Eine **Teilerstattung** setzt den Status bewusst *nicht* auf `REFUNDED` (der Restbetrag ist weiterhin eingezogen), sondern wird nur protokolliert. Der Bestellstatus und der Lagerbestand bleiben in beiden Fällen unangetastet: ob eine Erstattung Storno, Retoure oder Kulanz bedeutet, steht am Event nicht.
- `charge.dispute.created` vermerkt eine Rückbuchung (Chargeback) in den Notizen der Bestellung und schreibt sie ins Server-Log. Die Stellungnahme gegenüber Stripe ist **fristgebunden** — vorher war eine Rückbuchung ausschließlich im Stripe-Dashboard zu sehen.
Für einen produktiven Webhook-Endpoint müssen alle sechs Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. `pnpm preflight` prüft das Abonnement.
> **`checkout.session.expired` ist nicht optional.** Ohne dieses Abonnement bleibt der beim Checkout reservierte Bestand jedes abgebrochenen Kaufs dauerhaft blockiert. Falls es doch einmal fehlt oder der Endpunkt nicht erreichbar war, fängt der CronJob `wabenhain-release-stale-orders` (siehe unten) das ab.
### Abgebrochener Checkout
Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafür: jeder nicht zu Ende geführte Checkout hält Ware fest, die niemand bezahlt hat. Drei Wege geben sie wieder frei:
| Wann | Was passiert |
|------|--------------|
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung |
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
Der letzte Fall war eine Lücke: schlug `stripe.checkout.sessions.create` fehl, blieb die Bestellung auf `NEU` **ohne** `stripeCheckoutSessionId` stehen. Damit griff keiner der drei Wege darüber — der Abbruch-Klick braucht die Sitzungs-ID, der CronJob ebenso, und ein Stripe-Event kann es ohne Sitzung nicht geben. Der reservierte Bestand blieb dauerhaft blockiert, und die Oberfläche schickte die Kundschaft dabei auf die Erfolgsseite („wir melden uns"), obwohl es keinen Weg gab, diese Bestellung je zu bezahlen. Heute wird stattdessen ein Fehler angezeigt, der ausdrücklich sagt, dass nichts abgebucht wurde. Lässt sich die Sitzungs-ID nach dem Anlegen nicht speichern, wird die Sitzung sofort wieder geschlossen und dieselbe Auflösung ausgelöst — eine Bestellung auf `NEU` ohne Sitzungs-ID kann so nicht mehr entstehen. Der CronJob räumt Altbestände aus der Zeit davor trotzdem ab.
**Solange die Zahlung nicht bestätigt ist, erscheint die Bestellung nicht im Kundenkonto.** `getOrdersForCustomer` blendet den Status `NEU` aus (`lib/account-data.ts`). Vorher stand ein gerade abgebrochener Kauf dort bis zu 45 Minuten lang mit Bestellnummer und Summe in der Liste und sah aus wie ein zustande gekommener Kauf. Bezahlte Bestellungen sind davon nicht betroffen — der Webhook setzt sie binnen Sekunden auf `BEZAHLT`. In der Bestellverfolgung (`/bestellung-verfolgen`, auch für Gastbestellungen) bleibt eine `NEU`-Bestellung auffindbar, trägt dort aber die Kennzeichnung **„Zahlung ausstehend"** statt eines nichtssagenden „Neu".
**Ein abgebrochener Checkout hinterlässt keine Bestellung.** Weder im Admin-Bereich noch im Kundenkonto bleibt etwas stehen — mit der Bestellung verschwinden auch ihre Positionen (`onDelete: Cascade`). Aufbewahrungspflichten entstehen dabei nicht: ohne Zahlung gibt es kein Geschäft und keine Rechnung. Die Ausnahme ist `checkout.session.async_payment_failed`: dort wurde tatsächlich eine Zahlung versucht und ist gescheitert, die Kundschaft rechnet mit einer Lieferung. Diese Bestellung bleibt als **STORNIERT** bestehen und die Kundschaft bekommt eine E-Mail dazu.
Die **32 Minuten** sind eine bewusste Abweichung von Stripes Voreinstellung (24 Stunden, `expires_at` in `api/checkout/route.ts`). Mit einem Tag Reservierungsdauer könnte jemand den Shop mit wenigen Klicks leerräumen, ohne einen Cent zu zahlen. Kürzer geht nicht: **Stripe lässt als Untergrenze 30 Minuten zu**, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren.
### Stornierung im Admin-Bereich
Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIERT**, passiert dreierlei in dieser Reihenfolge (`admin/actions/orders.ts`):
1. Status setzen und Lagerbestand zurückbuchen — atomar in einer Transaktion, damit zwei offene Tabs den Bestand nicht doppelt gutschreiben.
2. **War die Bestellung bezahlt, wird der volle Betrag über die Stripe Refunds API erstattet** und `Payment.status` auf `REFUNDED` gesetzt. Vorher unterblieb das: die Bestellung galt als storniert, die Ware lag wieder im Regal, und das Geld blieb beim Shop. Teilerstattungen laufen weiterhin über den Retouren-Bereich, wo sie hingehören.
3. **War die Bestellung noch `NEU`, wird die offene Stripe-Sitzung geschlossen.** Sonst konnte die Kundschaft Minuten später noch bezahlen — der Webhook fand die Bestellung dann nicht mehr auf `NEU`, brach ab, und das Geld lag bei Stripe, ohne dass im Shop irgendetwas davon zu sehen war.
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
## Versandkosten ## Versandkosten
@@ -268,6 +308,16 @@ Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://loc
pnpm preflight pnpm preflight
``` ```
**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, kein SMTP und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist.
Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen:
```bash
kubectl exec -n wabenhain deploy/wabenhain-app -- pnpm preflight
```
Der Exit-Code ist dabei derselbe (1, solange ein Blocker offen ist), der Aufruf lässt sich also unverändert als letzter Schritt in die Deploy-Pipeline hängen.
Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem: Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem:
- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar). - `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar).
@@ -289,7 +339,7 @@ Sobald Stripe eine Zahlung bestätigt, erzeugt der Webhook (`api/checkout/webhoo
**Ablage** unter `INVOICE_DIR`, nach Jahr und Kunde sortiert: **Ablage** unter `INVOICE_DIR`, nach Jahr und Kunde sortiert:
``` ```
invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf invoices/2026/mustermann-erika/RE-2026-0001.pdf
``` ```
Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abgelaufener Jahrgang lässt sich als ganzes Verzeichnis archivieren. Der Pfad wird als `Invoice.pdfPath` gespeichert, damit eine spätere Änderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Zwei Personen mit identischem Namen teilen sich einen Ordner — bewusst, denn ein technisches Kürzel im Ordnernamen macht die Ablage für Menschen schlechter durchsuchbar, und die Rechnungsnummer im Dateinamen ist ohnehin eindeutig. Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abgelaufener Jahrgang lässt sich als ganzes Verzeichnis archivieren. Der Pfad wird als `Invoice.pdfPath` gespeichert, damit eine spätere Änderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Zwei Personen mit identischem Namen teilen sich einen Ordner — bewusst, denn ein technisches Kürzel im Ordnernamen macht die Ablage für Menschen schlechter durchsuchbar, und die Rechnungsnummer im Dateinamen ist ohnehin eindeutig.
@@ -328,7 +378,15 @@ Der Satz wird je **Bestellposition** als `OrderItem.vatRatePercent` festgeschrie
`pnpm preflight` blockiert einen fehlenden oder unplausiblen `VAT_RATE_PERCENT`. `pnpm preflight` blockiert einen fehlenden oder unplausiblen `VAT_RATE_PERCENT`.
**Noch offen:** die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer. ### Rechnungsnummer
Die Nummer hat die Form `RE-<Jahr>-<laufende Nummer>`, zum Beispiel `RE-2026-0001`. § 14 Abs. 4 Nr. 4 UStG verlangt eine **fortlaufende** Nummer — eine bloß eindeutige genügt nicht, weil sich mit ihr bei einer Betriebsprüfung nicht zeigen ließe, dass keine Rechnung fehlt. Die Zählung beginnt in jedem Jahr wieder bei `0001`; das ist zulässig, solange das Jahr Teil der Nummer ist.
Die laufende Nummer kommt aus der Tabelle `invoice_counters` (eine Zeile je Jahr) und wird zusammen mit der Rechnung in **einer** Transaktion vergeben. Eine Vergabe nach dem Muster „höchste vorhandene Nummer + 1“ wäre anfällig dafür, dass zwei gleichzeitig eintreffende Stripe-Webhooks dieselbe Nummer lesen und zweimal vergeben; die Zeilensperre beim Hochzählen schließt das aus.
Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine Rechnung existiert. Stripe stellt denselben Webhook bei ausbleibender Bestätigung erneut zu — ohne diese Prüfung verbrauchte jeder Wiederholungsversuch eine Nummer und riße eine Lücke in die Folge.
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
## Health-Endpunkte ## Health-Endpunkte
@@ -368,7 +426,7 @@ Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen. - **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren). - **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren).
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen. - **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und behandelt eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) wie einen fehlgeschlagenen Zahlungseinzug. - **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank. - **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`. - **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`.
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`. - **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
+91
View File
@@ -157,3 +157,94 @@ spec:
- name: invoices - name: invoices
persistentVolumeClaim: persistentVolumeClaim:
claimName: wabenhain-invoices claimName: wabenhain-invoices
---
# Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen.
#
# WARUM: eine Bestellung entsteht VOR der Zahlung und reserviert dabei sofort
# Lagerbestand — sonst koennte zwischen Kaufentscheidung und Zahlung jemand
# anders dasselbe Glas kaufen. Zurueck kommt der Bestand normalerweise ueber
# zwei Wege: den Abbruch-Knopf bei Stripe (/checkout/cancel) und das Ereignis
# checkout.session.expired, das Stripe an den Webhook schickt.
#
# Dieser CronJob ist das Netz unter beiden. Er greift genau dann, wenn der
# Webhook nicht ankommt: das Ereignis ist im Stripe-Dashboard nicht abonniert,
# der Endpunkt war beim Zustellversuch nicht erreichbar, oder die Versuche
# sind abgelaufen. Ohne ihn bliebe der reservierte Bestand dauerhaft
# blockiert — und zwar unsichtbar, denn eine solche Bestellung sieht im
# Admin-Bereich aus wie jede andere offene.
#
# Gefragt wird immer Stripe, nie die Uhr allein: zurueckgebucht wird nur, was
# dort nachweislich abgelaufen ist (siehe scripts/release-stale-orders.ts).
#
# Der Lauf endet mit Exit-Code 1, wenn er eine Bestellung findet, die bei
# Stripe bezahlt ist, hier aber noch auf "NEU" steht. Das ist Absicht: dieser
# Fall bedeutet entgangenen Umsatz und gehoert als fehlgeschlagener Job
# sichtbar gemacht, nicht in eine Logzeile.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wabenhain-release-stale-orders
namespace: wabenhain
spec:
# Alle 30 Minuten. Die Stripe-Sitzung laeuft nach 32 Minuten ab, der Lauf
# betrachtet Bestellungen ab 45 Minuten Alter — haeufiger zu pruefen bringt
# deshalb nichts, seltener laesst Ware unnoetig lange liegen.
schedule: "*/30 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
# Fehlgeschlagene Laeufe laenger aufheben: nur an ihnen laesst sich per
# "kubectl logs" nachlesen, welche Bestellung bezahlt, aber nicht
# verarbeitet ist.
failedJobsHistoryLimit: 5
startingDeadlineSeconds: 600
jobTemplate:
spec:
# Kein endloses Wiederholen: schlaegt der Lauf zweimal fehl, laeuft er
# ohnehin in 30 Minuten erneut.
backoffLimit: 2
# Nach einer Stunde abbrechen — ein haengender Lauf wuerde sonst jeden
# weiteren blockieren (concurrencyPolicy: Forbid).
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: OnFailure
# Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid
# 1001 nextjs) — dasselbe Image, derselbe Nutzer.
securityContext:
runAsUser: 1001
runAsGroup: 1001
containers:
- name: release-stale-orders
# DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml.
# Beide zusammen aktualisieren: der Lauf nutzt denselben
# Prisma-Client wie die App und muss deshalb zum selben
# Datenbankschema passen.
image: git.encry.de/nick.rosenloecher/wabenhain:latest
imagePullPolicy: IfNotPresent
# Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY
# — die stehen bereits in denselben beiden Quellen wie fuer die
# App, deshalb hier keine eigene Liste, die auseinanderlaufen
# koennte.
envFrom:
- configMapRef:
name: wabenhain-config
- secretRef:
name: wabenhain-secrets
command: ["/bin/sh", "-c"]
args:
- |
set -eu
# prisma-provider.mjs schreibt die provider-Zeile in
# prisma/schema.prisma anhand von DATABASE_PROVIDER — ohne
# diesen Schritt laedt der Prisma-Client den falschen
# Treiber, wenn die Installation auf MySQL laeuft.
node scripts/prisma-provider.mjs
exec tsx scripts/release-stale-orders.ts
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
# Derselbe Wert wie beim App-Container: der Lauf laedt
# denselben Prisma-Client samt Treiber.
memory: 512Mi
+2 -1
View File
@@ -17,7 +17,8 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"preflight": "tsx scripts/preflight.ts" "preflight": "tsx scripts/preflight.ts",
"orders:release-stale": "tsx scripts/release-stale-orders.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-mariadb": "^7.10.0", "@prisma/adapter-mariadb": "^7.10.0",
@@ -0,0 +1,15 @@
-- Laufende Rechnungsnummer je Kalenderjahr.
--
-- § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer. Bisher wurde eine
-- Zufallsnummer vergeben: eindeutig, aber nicht fortlaufend.
--
-- Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
-- sperrt die Datenbank diese eine Zeile, wodurch zwei gleichzeitig bezahlte
-- Bestellungen zwangslaeufig verschiedene Nummern erhalten.
-- CreateTable
CREATE TABLE "invoice_counters" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "invoice_counters_pkey" PRIMARY KEY ("year")
);
@@ -0,0 +1,16 @@
-- Abbruch-Token fuer die cancel_url der Stripe-Sitzung.
--
-- Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
-- Lagerbestand. Brach die Kundschaft den Checkout ab, blieb dieser Bestand
-- bisher blockiert, bis Stripe die Sitzung fuer abgelaufen erklaerte — nach
-- Voreinstellung 24 Stunden spaeter. Der Token steht ausschliesslich in der
-- cancel_url und gibt den Bestand beim Abbruch sofort frei.
--
-- Nullable, weil alle bereits bestehenden Bestellungen keinen haben; sie
-- laufen weiterhin ueber den Ablauf der Stripe-Sitzung.
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "cancelToken" TEXT;
-- CreateIndex
-- Eindeutig, damit ein Token immer genau eine Bestellung freigibt.
CREATE UNIQUE INDEX "orders_cancelToken_key" ON "orders"("cancelToken");
@@ -0,0 +1,22 @@
-- CreateIndex
-- PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders als
-- MySQL/InnoDB, das ihn automatisch anlegt). "order_items" ist die am
-- schnellsten wachsende Tabelle des Shops — je Bestellung eine Zeile pro
-- Position, und geloescht wird dort nichts. Ohne diesen Index laeuft jede
-- Abfrage nach productId als vollstaendiger Tabellenscan.
--
-- Zwei Stellen vertragen das nicht:
--
-- 1. hasCustomerPurchased (src/lib/reviews.ts) — der Nachweis
-- "verifizierter Kauf". Er laeuft bei JEDEM Aufruf einer Produktseite
-- durch eine angemeldete Kundschaft, also auf dem meistbesuchten
-- Seitentyp ueberhaupt.
-- 2. Das Loeschen eines Produkts im Admin-Bereich: die product-Relation ist
-- "onDelete: Restrict", die Datenbank muss also pruefen, ob noch
-- Positionen daran haengen.
CREATE INDEX "order_items_productId_idx" ON "order_items"("productId");
-- CreateIndex
-- Gleicher Grund: die batch-Relation ist ebenfalls "onDelete: Restrict". Das
-- Loeschen einer Charge im Admin-Bereich prueft gegen dieselbe Tabelle.
CREATE INDEX "order_items_batchId_idx" ON "order_items"("batchId");
+45 -1
View File
@@ -399,6 +399,14 @@ model Order {
discountCodeId String? discountCodeId String?
stripeCheckoutSessionId String? stripeCheckoutSessionId String?
/// Nicht erratbares Kennzeichen, das ausschliesslich in der cancel_url der
/// Stripe-Sitzung steht. Bricht die Kundschaft dort ab, landet sie auf
/// /checkout/cancel?ref=<token>; erst dieser Token weist nach, dass der
/// Abbruch zu DIESER Bestellung gehoert, und gibt den reservierten Bestand
/// sofort wieder frei. Die Bestellnummer taugt dafuer nicht: sie steht in
/// jeder Bestaetigungsmail und auf jeder Rechnung, mit ihr koennte also
/// jemand die noch offene Zahlung einer fremden Person abbrechen.
cancelToken String? @unique
notes String? notes String?
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim /// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail). /// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
@@ -483,6 +491,22 @@ model OrderItem {
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin, /// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
/// Rechnung) ueber die order.items-Relation abgefragt. /// Rechnung) ueber die order.items-Relation abgefragt.
@@index([orderId]) @@index([orderId])
/// PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders
/// als MySQL/InnoDB) — ohne diesen hier laeuft jede Abfrage nach productId
/// als vollstaendiger Scan ueber die am schnellsten wachsende Tabelle des
/// Shops. Betroffen sind zwei Stellen, die das nicht vertragen:
///
/// 1. hasCustomerPurchased (lib/reviews.ts) — der Nachweis "verifizierter
/// Kauf", der bei JEDEM Aufruf einer Produktseite durch eine
/// angemeldete Kundschaft ausgefuehrt wird.
/// 2. onDelete: Restrict auf der product-Relation — die Datenbank muss
/// beim Loeschen eines Produkts pruefen, ob noch Positionen daran
/// haengen.
@@index([productId])
/// Aus demselben Grund wie productId: die batch-Relation ist ebenfalls
/// "Restrict", das Loeschen einer Charge im Admin-Bereich prueft also gegen
/// diese Tabelle.
@@index([batchId])
@@map("order_items") @@map("order_items")
} }
@@ -538,6 +562,26 @@ model Pickup {
@@map("pickups") @@map("pickups")
} }
/// Laufende Nummer je Kalenderjahr fuer die Rechnungsstellung.
///
/// § 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Nummer. Eine
/// Zufallsnummer waere zwar eindeutig, aber nicht fortlaufend — bei einer
/// Betriebspruefung liesse sich damit nicht zeigen, dass keine Rechnung
/// fehlt.
///
/// Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
/// sperrt die Datenbank genau diese eine Zeile, zwei gleichzeitig bezahlte
/// Bestellungen bekommen dadurch zwangslaeufig verschiedene Nummern. Ein
/// Maximum-Abfragen waere dagegen ein klassisches Wettrennen — beide lesen
/// dieselbe hoechste Nummer, beide zaehlen auf denselben Wert hoch.
model InvoiceCounter {
/// Kalenderjahr; je Jahr beginnt die Zaehlung neu bei 1.
year Int @id
lastNumber Int @default(0)
@@map("invoice_counters")
}
model Invoice { model Invoice {
id String @id @default(cuid()) id String @id @default(cuid())
orderId String @unique orderId String @unique
@@ -549,7 +593,7 @@ model Invoice {
// Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent), // Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent),
// und die Aufstellung je Satz rechnet lib/vat.ts daraus aus. // und die Aufstellung je Satz rechnet lib/vat.ts daraus aus.
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B. /// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
/// "2026/mustermann-erika/RE-2026-A3F9C1B2.pdf". Siehe lib/invoice-storage.ts. /// "2026/mustermann-erika/RE-2026-0001.pdf". Siehe lib/invoice-storage.ts.
/// ///
/// Null bei Rechnungen aus der Zeit vor dieser Ablage: deren PDF wird beim /// Null bei Rechnungen aus der Zeit vor dieser Ablage: deren PDF wird beim
/// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal /// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal
+135 -14
View File
@@ -24,6 +24,7 @@ import path from "node:path";
import { prisma } from "../src/lib/prisma"; import { prisma } from "../src/lib/prisma";
import { getContentValue } from "../src/lib/content"; import { getContentValue } from "../src/lib/content";
import { locales } from "../src/i18n/config"; import { locales } from "../src/i18n/config";
import { getStripeClient } from "../src/lib/stripe";
const blockers: string[] = []; const blockers: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
@@ -42,6 +43,33 @@ function should(label: string, ok: boolean, hint: string): void {
const value = (name: string): string => (process.env[name] ?? "").trim(); const value = (name: string): string => (process.env[name] ?? "").trim();
const isSet = (name: string): boolean => value(name).length > 0; const isSet = (name: string): boolean => value(name).length > 0;
/**
* Eine Regel, die einen GESETZTEN Wert voraussetzt.
*
* Ohne diese Weiche meldete der Bericht gruene Haken fuer Variablen, die gar
* nicht existieren: "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein
* Platzhalter" stand als bestanden da, weil eine leere Zeichenkette nun einmal
* kein "example.com" enthaelt — direkt neben dem Blocker
* "NEXT_PUBLIC_SITE_URL gesetzt". Dasselbe galt fuer den Test auf einen
* sk_test-Schluessel: ohne jeden Stripe-Schluessel galt der Live-Modus als
* bestaetigt.
*
* Das ist die gefaehrlichste Art von Fehlmeldung, die eine Go-Live-Pruefung
* machen kann — sie beruhigt ueber genau den Punkt, der offen ist. Fehlt der
* Wert, entfaellt die Folgeregel hier stillschweigend; gemeldet wird er
* ohnehin schon von der Regel, die seine blosse Existenz prueft.
*/
function mustWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
must(label, ok(value(name)), hint);
}
/** Wie mustWhenSet, nur als Warnung statt als Blocker. */
function shouldWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
should(label, ok(value(name)), hint);
}
// --- Sitzungs-/Zugangsgeheimnisse ----------------------------------------- // --- Sitzungs-/Zugangsgeheimnisse -----------------------------------------
const sessionSecret = value("SESSION_SECRET"); const sessionSecret = value("SESSION_SECRET");
must( must(
@@ -49,9 +77,10 @@ must(
sessionSecret.length >= 32, sessionSecret.length >= 32,
"Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32" "Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32"
); );
must( mustWhenSet(
"SESSION_SECRET",
"SESSION_SECRET ist nicht mehr der Beispielwert", "SESSION_SECRET ist nicht mehr der Beispielwert",
!sessionSecret.includes("change-me"), (raw) => !raw.includes("change-me"),
"Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar." "Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar."
); );
@@ -74,14 +103,16 @@ must(
siteUrl.length > 0, siteUrl.length > 0,
"Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)." "Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)."
); );
must( mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost", "NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
siteUrl.startsWith("https://") && !siteUrl.includes("localhost"), (raw) => raw.startsWith("https://") && !raw.includes("localhost"),
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap." "Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
); );
must( mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter", "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
!siteUrl.includes("example.com") && !siteUrl.includes("example.org"), (raw) => !raw.includes("example.com") && !raw.includes("example.org"),
'Die echte Domain steht in k8s/01-configmap.yaml (NEXT_PUBLIC_SITE_URL) und k8s/06-ingress.yaml (host/tls.hosts) und muss an beiden Stellen dieselbe sein. Steht hier eine Beispiel-Domain, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.' 'Die echte Domain steht in k8s/01-configmap.yaml (NEXT_PUBLIC_SITE_URL) und k8s/06-ingress.yaml (host/tls.hosts) und muss an beiden Stellen dieselbe sein. Steht hier eine Beispiel-Domain, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.'
); );
@@ -96,9 +127,10 @@ must(
isSet("STRIPE_WEBHOOK_SECRET"), isSet("STRIPE_WEBHOOK_SECRET"),
'Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.' 'Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.'
); );
must( mustWhenSet(
"STRIPE_SECRET_KEY",
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)", "Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
!value("STRIPE_SECRET_KEY").startsWith("sk_test"), (raw) => !raw.startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht." "Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
); );
@@ -108,9 +140,12 @@ must(
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"), isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"),
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung." "Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
); );
should( // Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
// pruefen, wenn tatsaechlich ein eigener Wert hinterlegt ist.
shouldWhenSet(
"SMTP_FROM",
"SMTP_FROM zeigt auf eine eigene Domain", "SMTP_FROM zeigt auf eine eigene Domain",
!value("SMTP_FROM").includes("example.com"), (raw) => !raw.includes("example.com"),
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner." "Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
); );
@@ -125,9 +160,10 @@ must(
vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100, vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100,
"Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch." "Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch."
); );
should( shouldWhenSet(
"VAT_RATE_PERCENT",
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert", "Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
["0", "7", "19"].includes(vatRate), (raw) => ["0", "7", "19"].includes(raw),
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.` `Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
); );
@@ -203,10 +239,15 @@ try {
`Noch offen: ${withPlaceholder.join(", ")}. Im Admin-Bereich unter "Inhalte" pflegbar — kein Deploy noetig. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig: Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.` `Noch offen: ${withPlaceholder.join(", ")}. Im Admin-Bereich unter "Inhalte" pflegbar — kein Deploy noetig. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig: Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.`
); );
// Ein LEERES Feld ist ausdruecklich in Ordnung und loest keine Warnung aus:
// ohne gepflegte Nummer entfaellt die Zeile auf der Kontaktseite und im
// Impressum einfach (siehe lib/site-config.ts). Gemeldet wird nur eine
// tatsaechlich eingetragene Platzhalternummer — die sieht nach einem
// Kontaktweg aus, an dessen Ende niemand abnimmt.
should( should(
"Telefonnummer ist keine Platzhalternummer", "Telefonnummer ist keine Platzhalternummer",
!(await getContentValue("company.phone", "de")).includes("000 000000"), !(await getContentValue("company.phone", "de")).includes("000 000000"),
'Sichtbar im Footer, auf der Kontaktseite und im Impressum. Im Admin-Bereich unter "Inhalte" pflegbar.' 'Eingetragen ist eine Platzhalternummer. Entweder die echte Nummer eintragen oder das Feld LEEREN — dann entfaellt die Angabe ueberall. Im Admin-Bereich unter "Inhalte" pflegbar.'
); );
} catch (error: unknown) { } catch (error: unknown) {
// Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst // Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst
@@ -221,10 +262,90 @@ try {
} }
} }
/**
* Prueft, ob der Stripe-Webhook-Endpunkt die vier Ereignisse abonniert hat,
* auf die der Shop angewiesen ist.
*
* Das ist keine Formalie: fehlt checkout.session.expired, bleibt der beim
* Checkout reservierte Lagerbestand jedes abgebrochenen Kaufs dauerhaft
* blockiert. Fehlt checkout.session.completed, wird ueberhaupt keine Zahlung
* bestaetigt. Beides faellt im Betrieb erst auf, wenn Ware unverkaeuflich im
* System steht oder Geld eingezogen wurde, ohne dass eine Rechnung entstand —
* beides zu spaet.
*/
const REQUIRED_WEBHOOK_EVENTS = [
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
// Geld, das den Shop wieder verlaesst, ohne dass hier jemand etwas
// angeklickt hat: eine Erstattung von Hand im Stripe-Dashboard und eine
// Rueckbuchung durch die Bank. Ohne diese beiden Ereignisse steht die
// Zahlung im Shop weiter als erfolgreich da, und eine fristgebundene
// Rueckbuchung faellt nur auf, wenn jemand ins Dashboard sieht.
"charge.refunded",
"charge.dispute.created",
] as const;
async function checkStripeWebhookEvents(): Promise<void> {
if (!isSet("STRIPE_SECRET_KEY")) return; // fehlt bereits als Blocker oben
try {
const stripe = getStripeClient();
const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
const active = endpoints.data.filter((endpoint) => endpoint.status === "enabled");
// "*" abonniert alle Ereignisse — dann ist alles Noetige dabei.
const covered = new Set<string>();
for (const endpoint of active) {
for (const event of endpoint.enabled_events) covered.add(event);
}
const missing = covered.has("*")
? []
: REQUIRED_WEBHOOK_EVENTS.filter((event) => !covered.has(event));
if (active.length === 0) {
warnings.push(
"Kein aktiver Stripe-Webhook-Endpunkt gefunden\n Ohne ihn wird keine Zahlung bestaetigt und kein abgebrochener Checkout aufgeraeumt. Unter https://dashboard.stripe.com/webhooks einen Endpunkt auf /api/checkout/webhook anlegen. Hinweis: mit einem eingeschraenkten Schluessel (rk_...) kann diese Pruefung die Liste moeglicherweise nicht sehen, obwohl ein Endpunkt existiert."
);
return;
}
must(
`Stripe-Webhook abonniert alle ${REQUIRED_WEBHOOK_EVENTS.length} benoetigten Ereignisse`,
missing.length === 0,
`Nicht abonniert: ${missing.join(", ")}. Besonders checkout.session.expired: ohne dieses Ereignis bleibt der Lagerbestand jedes abgebrochenen Checkouts blockiert. Unter https://dashboard.stripe.com/webhooks am Endpunkt nachtragen.`
);
} catch (error: unknown) {
// Weder Blocker noch stilles Bestehen: der Schluessel darf die Liste
// vielleicht gar nicht lesen (eingeschraenkter Schluessel), und daraus
// laesst sich nichts ueber die tatsaechliche Konfiguration ableiten.
warnings.push(
`Stripe-Webhook-Ereignisse konnten NICHT geprueft werden\n ${firstMeaningfulLine(
error
)}. Im Dashboard unter https://dashboard.stripe.com/webhooks von Hand nachsehen, ob ${REQUIRED_WEBHOOK_EVENTS.join(
", "
)} abonniert sind.`
);
}
}
function report(): void { function report(): void {
// --- Ausgabe --------------------------------------------------------------- // --- Ausgabe ---------------------------------------------------------------
const line = "─".repeat(72); const line = "─".repeat(72);
console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`); console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`);
// Ohne diesen Hinweis wird der Bericht regelmaessig falsch gelesen: auf einem
// Entwicklungsrechner meldet er Blocker fuer Werte, die in der ConfigMap des
// Clusters laengst richtig stehen (NEXT_PUBLIC_SITE_URL, SMTP_FROM,
// VAT_RATE_PERCENT). Wer das ein paarmal erlebt, sieht ueber den Bericht
// hinweg — und damit auch ueber den einen Blocker, der echt ist.
console.log(
`
Geprueft wird die Umgebung, in der dieses Skript LAEUFT. Fuer eine Aussage
ueber den Live-Betrieb im Pod ausfuehren, nicht auf dem eigenen Rechner —
siehe README, Abschnitt "Vor dem Live-Gang".`
);
console.log(`\n✔ Bestanden: ${passed.length}`); console.log(`\n✔ Bestanden: ${passed.length}`);
for (const item of passed) console.log(` ✔ ${item}`); for (const item of passed) console.log(` ✔ ${item}`);
@@ -249,4 +370,4 @@ console.log(
); );
} }
void checkDatabaseContent().then(report); void Promise.all([checkDatabaseContent(), checkStripeWebhookEvents()]).then(report);
+153
View File
@@ -0,0 +1,153 @@
/**
* Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen.
*
* Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
* Lagerbestand. Drei Wege geben ihn normalerweise wieder frei:
*
* 1. der Abbruch durch die Kundschaft (checkout/cancel),
* 2. das Ereignis checkout.session.expired von Stripe (Webhook),
* 3. dieser Lauf hier.
*
* Die ersten beiden sind die Regel, dieser hier ist das Netz darunter. Er
* faengt genau die Faelle ab, in denen Weg 2 nicht funktioniert: das Ereignis
* checkout.session.expired ist im Stripe-Dashboard nicht abonniert, der
* Endpunkt war beim Zustellversuch nicht erreichbar, oder die Zustellversuche
* sind abgelaufen. Ohne dieses Netz bliebe der reservierte Bestand dann fuer
* immer blockiert — unsichtbar, denn im Admin-Bereich sieht eine solche
* Bestellung aus wie jede andere offene.
*
* Gefragt wird immer Stripe, nie die Uhr allein: nur wenn die Sitzung dort
* nachweislich abgelaufen ist, wird zurueckgebucht. Ein Lauf, der nur nach
* dem Alter ginge, koennte eine gerade laufende Zahlung unter den Fuessen
* wegstornieren.
*
* Aufruf: pnpm orders:release-stale
* Im Cluster laeuft er als CronJob (siehe k8s/07-backup-cronjob.yaml).
*/
import { prisma } from "../src/lib/prisma";
import { getStripeClient } from "../src/lib/stripe";
import { releaseUnpaidOrder } from "../src/lib/order-release";
/**
* Mindestalter, bevor eine Bestellung ueberhaupt betrachtet wird.
*
* Die Stripe-Sitzung laeuft nach 32 Minuten ab (siehe
* CHECKOUT_SESSION_TTL_MINUTES in app/api/checkout/route.ts). 45 Minuten
* lassen dem regulaeren Weg — Ablauf bei Stripe, Ereignis, Webhook — genug
* Luft, seine Arbeit zuerst zu tun.
*/
const MIN_AGE_MINUTES = 45;
/** Obergrenze je Lauf, damit ein Rueckstand die Datenbank nicht am Stueck belastet. */
const MAX_ORDERS_PER_RUN = 200;
async function main(): Promise<void> {
const cutoff = new Date(Date.now() - MIN_AGE_MINUTES * 60 * 1000);
// Bewusst OHNE Filter auf stripeCheckoutSessionId: genau die Bestellungen
// ohne hinterlegte Sitzung waren frueher das Loch in diesem Netz. Sie
// wurden hier uebergangen, der Abbruch-Klick kam ohne Sitzungs-ID ebenfalls
// nicht weiter, und ein Ereignis von Stripe konnte es ohne Sitzung gar
// nicht geben — ihr reservierter Lagerbestand blieb dauerhaft blockiert.
const candidates = await prisma.order.findMany({
where: {
status: "NEU",
createdAt: { lt: cutoff },
},
select: { id: true, orderNumber: true, stripeCheckoutSessionId: true },
orderBy: { createdAt: "asc" },
take: MAX_ORDERS_PER_RUN,
});
if (candidates.length === 0) {
console.log("[release-stale-orders] Keine liegengebliebenen Bestellungen.");
return;
}
console.log(
`[release-stale-orders] ${candidates.length} offene Bestellung(en) werden bei Stripe geprueft.`
);
const stripe = getStripeClient();
let released = 0;
let stillOpen = 0;
let paid = 0;
let failed = 0;
for (const order of candidates) {
try {
if (!order.stripeCheckoutSessionId) {
// Seit api/checkout/route.ts die Sitzung wieder schliesst, wenn sich
// ihre ID nicht speichern laesst, sollte es das nicht mehr geben. Es
// bleibt aber der Fall, dass auch dieses Schliessen fehlschlug, und
// Altbestaende aus der Zeit davor. Beide sind aelter als MIN_AGE_MINUTES
// und damit aelter als CHECKOUT_SESSION_TTL_MINUTES: eine Sitzung, die
// es noch gaebe, waere zu diesem Zeitpunkt in jedem Fall abgelaufen.
// Der Bestand kann also gefahrlos zurueck.
const result = await releaseUnpaidOrder(order.id, "delete");
if (result) {
released += 1;
console.warn(
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt — zu dieser Bestellung war KEINE Stripe-Sitzung hinterlegt, sie war also nie bezahlbar.`
);
}
continue;
}
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (session.status === "complete") {
// Bezahlt, aber der Status steht noch auf "NEU": der
// checkout.session.completed-Webhook ist nicht angekommen. Diesen Fall
// loest der Lauf hier bewusst NICHT auf — er wuerde Rechnung,
// Bestaetigungsmail und Gutscheinzaehlung ueberspringen. Stattdessen
// laut melden, damit die Zustellung im Stripe-Dashboard wiederholt
// werden kann.
paid += 1;
console.error(
`[release-stale-orders] Bestellung ${order.orderNumber} ist bei Stripe BEZAHLT, steht hier aber auf "NEU". Der Webhook ist nicht angekommen — bitte im Stripe-Dashboard erneut zustellen.`
);
continue;
}
if (session.status === "open") {
// Sitzung laeuft noch (moeglich bei Zahlungsarten mit langer Frist).
// Nicht anfassen.
stillOpen += 1;
continue;
}
// "delete": nie bezahlt, nie bestaetigt — die Vormerkung verschwindet
// mitsamt der Bestellung, statt als "STORNIERT" im Admin-Bereich wie
// ein gescheiterter Verkauf auszusehen.
const result = await releaseUnpaidOrder(order.id, "delete");
if (result) {
released += 1;
console.log(
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt (Sitzung abgelaufen).`
);
}
} catch (error: unknown) {
// Eine einzelne fehlgeschlagene Abfrage darf den Lauf nicht beenden —
// die uebrigen Bestellungen sollen trotzdem geprueft werden.
failed += 1;
console.error(`[release-stale-orders] ${order.orderNumber} konnte nicht geprueft werden:`, error);
}
}
console.log(
`[release-stale-orders] Fertig: ${released} freigegeben, ${stillOpen} noch offen, ${paid} bezahlt aber ohne Webhook, ${failed} fehlgeschlagen.`
);
// Ein bezahlter, aber nicht verarbeiteter Fall ist ein echter Missstand und
// soll den CronJob als fehlgeschlagen markieren, damit er im Cluster
// auffaellt statt nur in einem Logeintrag zu verschwinden.
if (paid > 0) process.exitCode = 1;
}
main()
.catch((error: unknown) => {
console.error("[release-stale-orders] Abgebrochen:", error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+138 -1
View File
@@ -7,7 +7,9 @@ import { sendMail } from "@/lib/mail";
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { restockOrderItems } from "@/lib/inventory"; import { restockOrderItems } from "@/lib/inventory";
import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { requireAdmin, str, strOrNull, fail } from "./_shared";
const ORDER_STATUSES = [ const ORDER_STATUSES = [
@@ -21,6 +23,99 @@ const ORDER_STATUSES = [
"STORNIERT", "STORNIERT",
] as const; ] as const;
/**
* Erledigt die Geldseite einer gerade ausgesprochenen Stornierung.
*
* Zwei Dinge, die eine Statusaenderung allein offen laesst:
*
* 1. WAR SCHON BEZAHLT — dann muss das Geld zurueck. Ohne diesen Schritt
* stand die Bestellung als "storniert" da, die Ware lag wieder im Regal,
* und der Betrag blieb trotzdem beim Shop. Erstattet wird der volle
* Betrag; eine Teilerstattung ist ueber den Retouren-Bereich abzuwickeln,
* wo sie hingehoert.
*
* 2. WAR NOCH OFFEN ("NEU") — dann ist die Stripe-Sitzung unter Umstaenden
* noch bezahlbar. Sie muss geschlossen werden, sonst kann die Kundschaft
* Minuten spaeter noch zahlen: der Webhook findet die Bestellung dann
* nicht mehr im Status "NEU", bricht ab, und das Geld liegt bei Stripe,
* ohne dass im Shop irgendetwas davon zu sehen waere.
*
* Fehler werden nicht geworfen, sondern als Klartext zurueckgegeben. Die
* Stornierung ist an dieser Stelle schon geschrieben und die Ware
* zurueckgebucht — sie deswegen abzubrechen wuerde nichts heilen. Was offen
* bleibt, gehoert stattdessen sichtbar vor die Imkerei, damit sie es von Hand
* erledigen kann.
*
* @returns leere Liste, wenn alles erledigt ist; sonst je offener Punkt einen
* Satz fuer die Fehlermeldung im Admin-Bereich.
*/
async function settleCancelledOrder(order: {
orderId: string;
orderNumber: string;
previousStatus: OrderStatus;
stripeCheckoutSessionId: string | null;
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
}): Promise<string[]> {
const warnings: string[] = [];
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
if (order.payment?.status === "SUCCEEDED") {
const paymentIntentId = order.payment.stripePaymentIntentId;
if (!paymentIntentId) {
warnings.push(
"Zu dieser bezahlten Bestellung ist keine Stripe-Zahlung hinterlegt — die Erstattung muss von Hand angestossen werden."
);
} else {
try {
const stripe = getStripeClient();
await stripe.refunds.create({ payment_intent: paymentIntentId });
await prisma.payment.update({
where: { orderId: order.orderId },
data: { status: "REFUNDED" },
});
} catch (error: unknown) {
console.error(
`[order-cancel] Erstattung fuer Bestellung ${order.orderNumber} (PaymentIntent ${paymentIntentId}) fehlgeschlagen:`,
error
);
warnings.push(
"Die Erstattung ueber Stripe ist fehlgeschlagen — bitte im Stripe-Dashboard von Hand erstatten."
);
}
}
}
if (order.previousStatus === "NEU" && order.stripeCheckoutSessionId) {
try {
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
// Nur eine offene Sitzung laesst sich schliessen. "complete" bedeutet,
// dass in der Zwischenzeit doch bezahlt wurde — dann ist das hier ein
// Wettlauf, den die Zahlung gewonnen hat, und er gehoert gemeldet statt
// stillschweigend uebergangen.
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
} else if (session.status === "complete") {
warnings.push(
"Achtung: Diese Bestellung wurde bei Stripe zwischenzeitlich doch bezahlt. Bitte den Zahlungseingang pruefen und die Erstattung von Hand anstossen."
);
}
} catch (error: unknown) {
console.error(
`[order-cancel] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geschlossen werden:`,
error
);
warnings.push(
"Die offene Zahlungssitzung konnte nicht geschlossen werden — sie laeuft von selbst ab, bis dahin ist eine Zahlung noch moeglich."
);
}
}
return warnings;
}
export async function updateOrderStatusAction(formData: FormData): Promise<void> { export async function updateOrderStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const orderId = str(formData, "orderId"); const orderId = str(formData, "orderId");
@@ -39,11 +134,19 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
email: true, email: true,
firstName: true, firstName: true,
locale: true, locale: true,
stripeCheckoutSessionId: true,
payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true } }, items: { select: { productId: true, quantity: true } },
}, },
}); });
if (!previous) return null; if (!previous) return null;
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
// beides darf bei einem zweiten Klick auf eine bereits stornierte
// Bestellung nicht noch einmal passieren.
let cancelledNow = false;
if (status === "STORNIERT") { if (status === "STORNIERT") {
// Atomarer Claim statt einfachem update(): verhindert, dass zwei // Atomarer Claim statt einfachem update(): verhindert, dass zwei
// schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene // schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene
@@ -58,6 +161,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
data: { status }, data: { status },
}); });
if (claim.count === 1) { if (claim.count === 1) {
cancelledNow = true;
await restockOrderItems(tx, previous.items); await restockOrderItems(tx, previous.items);
} }
} else { } else {
@@ -88,7 +192,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
}); });
} }
return { ...previous, shipment }; return { ...previous, shipment, cancelledNow };
}); });
if (!result) { if (!result) {
@@ -103,6 +207,39 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
detail: `${result.orderNumber}: ${result.status} → ${status}`, detail: `${result.orderNumber}: ${result.status} → ${status}`,
}); });
// Die Geldseite der Stornierung — bewusst NACH der Transaktion: ein
// Stripe-Aufruf dauert ein Vielfaches einer Datenbankoperation und wuerde
// die Zeilensperren auf Bestellung und Lagerbestand so lange halten.
//
// Bis hierher hat die Stornierung nur den Status gesetzt und die Ware
// zurueckgebucht. Ohne die beiden folgenden Schritte blieb eine
// Stornierung auf halbem Weg stehen: bei einer bezahlten Bestellung
// behielt der Shop das Geld, bei einer noch offenen blieb die
// Zahlungssitzung bezahlbar.
if (result.cancelledNow) {
const stornoWarnings = await settleCancelledOrder({
orderId,
orderNumber: result.orderNumber,
previousStatus: result.status,
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
payment: result.payment,
});
if (stornoWarnings.length > 0) {
await logAdminAction(admin, {
action: "order.cancel_incomplete",
entityType: "Order",
entityId: orderId,
detail: `${result.orderNumber}: ${stornoWarnings.join(" | ")}`,
});
// Die Stornierung selbst steht und wird NICHT zurueckgenommen — die
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
}
}
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach // E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht // der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts). // rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
+56 -30
View File
@@ -130,29 +130,37 @@ export default async function AdminDashboardPage() {
_count: true, _count: true,
where: { status: { not: "STORNIERT" } }, where: { status: { not: "STORNIERT" } },
}), }),
// Fuer Top-Produkte/Umsatz nach Kategorie: kein Prisma-groupBy ueber // Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
// Relationen moeglich, deshalb Rohdaten laden und im Code aggregieren // JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
// (bei der Groessenordnung dieses Shops unproblematisch). // nur um daraus fuenf Zeilen "Top-Produkte" zu rechnen. "order_items" ist
prisma.orderItem.findMany({ // die am schnellsten wachsende Tabelle des Shops und wird nie
// aufgeraeumt: die Abfrage wurde mit jedem Verkauf teurer — auf der Seite,
// die im Admin-Bereich als erste geoeffnet wird.
//
// Gruppiert wird nach productId UND productName, weil productName ein
// Schnappschuss vom Bestellzeitpunkt ist: ein spaeter umbenanntes Produkt
// ergibt zwei Gruppen. Sie werden unten wieder nach productId
// zusammengefuehrt — genau wie bisher. Die Kategorie steht nicht an der
// Position und kommt deshalb aus einer zweiten, kurzen Abfrage (siehe
// unten); ueber eine Relation laesst sich nicht gruppieren.
prisma.orderItem.groupBy({
by: ["productId", "productName"],
where: { order: { status: { not: "STORNIERT" } } }, where: { order: { status: { not: "STORNIERT" } } },
select: { _sum: { quantity: true, totalCents: true },
productId: true,
productName: true,
quantity: true,
totalCents: true,
product: { select: { category: true } },
},
}), }),
prisma.order.findMany({ prisma.order.findMany({
where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } }, where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } },
select: { createdAt: true, totalCents: true }, select: { createdAt: true, totalCents: true },
}), }),
// Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung. // Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung.
// Kein Prisma-groupBy mit HAVING count > 1 in einer Abfrage moeglich, // Zaehlen und filtern erledigt die Datenbank per HAVING; zurueck kommt
// deshalb Rohdaten laden und im Code gruppieren. // eine Zeile je Stammkundin/Stammkunde. Frueher wurde hier JEDE Bestellung
prisma.order.findMany({ // geladen, die es je gab, nur um im Speicher eine einzige Zahl daraus zu
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
prisma.order.groupBy({
by: ["customerId"],
where: { customerId: { not: null }, status: { not: "STORNIERT" } }, where: { customerId: { not: null }, status: { not: "STORNIERT" } },
select: { customerId: true }, having: { customerId: { _count: { gt: 1 } } },
}), }),
prisma.discountCode.findMany({ prisma.discountCode.findMany({
where: { usedCount: { gt: 0 } }, where: { usedCount: { gt: 0 } },
@@ -161,12 +169,24 @@ export default async function AdminDashboardPage() {
}), }),
]); ]);
const ordersPerCustomer = new Map<string, number>(); // Eine Zeile je Stammkundschaft — das HAVING oben hat bereits gefiltert.
for (const order of repeatCustomerGroups) { const repeatCustomerCount = repeatCustomerGroups.length;
if (!order.customerId) continue;
ordersPerCustomer.set(order.customerId, (ordersPerCustomer.get(order.customerId) ?? 0) + 1); // Die Kategorie haengt am Produkt, nicht an der Bestellposition, und laesst
} // sich deshalb nicht mitgruppieren. Diese Nachfrage betrifft nur die
const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length; // tatsaechlich verkauften Produkte — eine Handvoll Zeilen ueber den
// Primaerschluessel, nicht die Bestellhistorie. Ein Produkt mit
// Bestellpositionen kann nicht geloescht werden (onDelete: Restrict), es
// gibt hier also zu jeder ID auch eine Zeile.
const soldProductIds = [...new Set(soldItems.map((group) => group.productId))];
const soldProducts =
soldProductIds.length > 0
? await prisma.product.findMany({
where: { id: { in: soldProductIds } },
select: { id: true, category: true },
})
: [];
const categoryByProductId = new Map(soldProducts.map((product) => [product.id, product.category]));
const statLabels: Record<StatKey, string> = { const statLabels: Record<StatKey, string> = {
revenueToday: t.adminDashboardPage.statRevenueToday, revenueToday: t.adminDashboardPage.statRevenueToday,
@@ -194,20 +214,26 @@ export default async function AdminDashboardPage() {
const productTotals = new Map<string, { name: string; quantity: number; revenueCents: number }>(); const productTotals = new Map<string, { name: string; quantity: number; revenueCents: number }>();
const categoryTotals = new Map<string, { quantity: number; revenueCents: number }>(); const categoryTotals = new Map<string, { quantity: number; revenueCents: number }>();
for (const item of soldItems) { for (const group of soldItems) {
const product = productTotals.get(item.productId) ?? { // Ein umbenanntes Produkt kommt als zwei Gruppen an (productName ist ein
name: item.productName, // Schnappschuss) — hier wieder zu einer Zeile zusammengefuehrt. Der zuerst
// gesehene Name gewinnt, wie zuvor.
const quantity = group._sum.quantity ?? 0;
const revenueCents = group._sum.totalCents ?? 0;
const product = productTotals.get(group.productId) ?? {
name: group.productName,
quantity: 0, quantity: 0,
revenueCents: 0, revenueCents: 0,
}; };
product.quantity += item.quantity; product.quantity += quantity;
product.revenueCents += item.totalCents; product.revenueCents += revenueCents;
productTotals.set(item.productId, product); productTotals.set(group.productId, product);
const category = item.product?.category ?? "HONIG"; const category = categoryByProductId.get(group.productId) ?? "HONIG";
const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 }; const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 };
categoryTotal.quantity += item.quantity; categoryTotal.quantity += quantity;
categoryTotal.revenueCents += item.totalCents; categoryTotal.revenueCents += revenueCents;
categoryTotals.set(category, categoryTotal); categoryTotals.set(category, categoryTotal);
} }
+30 -6
View File
@@ -2,7 +2,14 @@ import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { createSession, hashPassword, getEntraOAuthCookie, clearEntraOAuthCookie } from "@/lib/auth"; import {
createSession,
hashPassword,
getEntraOAuthCookie,
clearEntraOAuthCookie,
setTwoFactorChallengeCookie,
} from "@/lib/auth";
import { signTwoFactorChallenge } from "@/lib/session";
import { import {
isEntraConfigured, isEntraConfigured,
exchangeEntraAuthorizationCode, exchangeEntraAuthorizationCode,
@@ -135,11 +142,28 @@ export async function GET(request: Request) {
}); });
} }
// Anmeldung per SSO wird bewusst als vollwertig authentifiziert behandelt // Fuer KUNDENkonten wird die Anmeldung per SSO bewusst als vollwertig
// und ueberspringt die App-eigene 2FA-Abfrage (falls fuer das Konto // authentifiziert behandelt und ueberspringt die App-eigene 2FA-Abfrage
// aktiviert): Microsoft ist hier die pruefende Authentifizierungsstelle, // (falls fuer das Konto aktiviert): Microsoft ist hier die pruefende
// deren eigene Absicherung (bis hin zu Microsofts eigener MFA) die Rolle // Authentifizierungsstelle, deren eigene Absicherung (bis hin zu Microsofts
// unseres TOTP-Schritts uebernimmt. // eigener MFA) die Rolle unseres TOTP-Schritts uebernimmt.
//
// Fuer ADMINkonten gilt das NICHT. requireAdmin (admin/actions/_shared.ts)
// laesst eine Admin-Aktion nur zu, wenn am Konto twoFactorEnabled steht —
// dieses Flag soll zusichern, dass hinter jeder Rueckerstattung und jedem
// Blick in die Kundendaten tatsaechlich ein zweiter Faktor stand. Ueber
// diesen Weg war das nur noch eine Annahme ueber die MFA-Richtlinie des
// Entra-Mandanten, die dieser Code nicht nachpruefen kann: ein Flag, das
// "geprueft" behauptet, obwohl hier nichts geprueft wurde. Deshalb laeuft
// ein Admin denselben TOTP-Schritt wie beim Passwort-Login. Ausgesperrt
// werden kann dabei niemand — twoFactorEnabled heisst, dass der zweite
// Faktor bereits eingerichtet ist.
if (customer.role === "ADMIN" && customer.twoFactorEnabled) {
const challengeToken = await signTwoFactorChallenge(customer.id);
await setTwoFactorChallengeCookie(challengeToken);
return NextResponse.redirect(new URL("/konto/zwei-faktor-code", baseUrl));
}
await createSession(customer.id, customer.role, customer.sessionVersion); await createSession(customer.id, customer.role, customer.sessionVersion);
return NextResponse.redirect(new URL("/konto", baseUrl)); return NextResponse.redirect(new URL("/konto", baseUrl));
} }
+270
View File
@@ -0,0 +1,270 @@
/**
* Tests fuer die Checkout-Route — die Stelle, an der eine Bestellung entsteht
* und dabei sofort Lagerbestand reserviert.
*
* Der Schwerpunkt liegt auf den Wegen, die schieflaufen koennen, nicht auf dem
* Normalfall: ein deaktiviertes Produkt, ein Preis von 0, ausverkaufte Ware
* und vor allem der Fall, dass Stripe nicht antwortet. Genau dort lag die
* Luecke, die diese Tests festhalten — eine Bestellung ohne Zahlungssitzung
* blieb auf "NEU" stehen und hielt ihren reservierten Bestand fuer immer fest,
* ohne dass irgendwo ein Fehler sichtbar wurde.
*/
import { describe, test, expect, beforeEach, vi } from "vitest";
const sessionsCreate = vi.fn();
const sessionsExpire = vi.fn();
const couponsCreate = vi.fn();
vi.mock("@/lib/stripe", () => ({
getStripeClient: () => ({
checkout: { sessions: { create: sessionsCreate, expire: sessionsExpire } },
coupons: { create: couponsCreate },
}),
}));
const getProductsByIds = vi.fn();
vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) }));
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: vi.fn(async () => null) }));
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
const verifyTurnstileToken =
vi.fn<(token: string | null, remoteIp?: string) => Promise<boolean>>(async () => true);
vi.mock("@/lib/turnstile", () => ({
verifyTurnstileToken: (token: string | null, remoteIp?: string) =>
verifyTurnstileToken(token, remoteIp),
}));
const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true);
vi.mock("@/lib/rate-limit", () => ({
rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs),
getClientIp: vi.fn(async () => "203.0.113.7"),
}));
vi.mock("@/lib/shipping", () => ({ getShippingCostCents: vi.fn(async () => 700) }));
vi.mock("@/lib/bulk-discount", () => ({ ensureBulkDiscountCode: vi.fn(async () => undefined) }));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/i18n/locale", () => ({ getLocale: vi.fn(async () => "de") }));
const releaseUnpaidOrder = vi.fn();
vi.mock("@/lib/order-release", () => ({
releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args),
}));
/** Siehe webhook/route.test.ts — dieselbe Begruendung fuer denselben Ersatz. */
const db = {
order: { create: vi.fn(), count: vi.fn(), update: vi.fn() },
inventory: { updateMany: vi.fn() },
discountCode: { findUnique: vi.fn(), findMany: vi.fn() },
$transaction: vi.fn(),
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
const { POST } = await import("./route");
const ORDER = { id: "order_1", orderNumber: "WH-2026-AABBCCDD" };
const honey = {
id: "p1",
name: "Fruehtracht 2026",
slug: "fruehtracht-2026",
priceCents: 990,
weightGrams: 500,
isActive: true,
category: "HONIG",
batchId: null,
vatRatePercent: null,
};
const validPayload = {
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
fulfillmentMethod: "VERSAND",
street: "Wiesenweg 1",
zip: "86450",
city: "Altenmünster",
country: "DE",
items: [{ productId: "p1", quantity: 2 }],
};
function post(payload: Record<string, unknown> = validPayload): Promise<Response> {
return POST(
new Request("https://wabenhain.test/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json", origin: "https://wabenhain.test" },
body: JSON.stringify(payload),
})
);
}
beforeEach(() => {
vi.clearAllMocks();
isTrustedOrigin.mockReturnValue(true);
rateLimit.mockReturnValue(true);
verifyTurnstileToken.mockResolvedValue(true);
getProductsByIds.mockResolvedValue([honey]);
db.discountCode.findMany.mockResolvedValue([]);
db.inventory.updateMany.mockResolvedValue({ count: 1 });
db.order.create.mockResolvedValue(ORDER);
db.$transaction.mockImplementation(async (arg: unknown) =>
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg
);
sessionsCreate.mockResolvedValue({
id: "cs_test_1",
url: "https://checkout.stripe.com/c/pay/cs_test_1",
});
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("Checkout-Route", () => {
describe("Torwaechter vor dem ersten Schreibzugriff", () => {
test("weist einen fremden Origin ab, ohne Bestand zu reservieren", async () => {
// Route-Handler haben keinen eingebauten CSRF-Schutz: ohne diese
// Pruefung koennte eine fremde Seite Bestellungen unter dem Konto einer
// eingeloggten Kundschaft anlegen.
isTrustedOrigin.mockReturnValue(false);
const response = await post();
expect(response.status).toBe(403);
expect(db.inventory.updateMany).not.toHaveBeenCalled();
});
test("weist eine fehlgeschlagene Sicherheitspruefung ab", async () => {
verifyTurnstileToken.mockResolvedValue(false);
const response = await post();
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("bremst zu viele Bestellversuche derselben Adresse", async () => {
rateLimit.mockReturnValue(false);
const response = await post();
expect(response.status).toBe(429);
expect(verifyTurnstileToken).not.toHaveBeenCalled();
});
});
describe("Was nicht verkauft werden darf", () => {
test("laesst ein im Admin deaktiviertes Produkt nicht durch", async () => {
// Ein Warenkorb, der im Browser lag, bevor das Produkt aus dem Verkauf
// genommen wurde, kam frueher bis zur Zahlung durch.
getProductsByIds.mockResolvedValue([{ ...honey, isActive: false }]);
const response = await post();
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("laesst ein Produkt ohne gepflegten Preis nicht durch", async () => {
// Preis 0 ist kein Geschenk, sondern ein fehlender Preis — aus ApiCore
// importierte Produkte kommen genau so an.
getProductsByIds.mockResolvedValue([{ ...honey, priceCents: 0 }]);
const response = await post();
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("bricht ab, wenn der Bestand waehrend des Checkouts nicht mehr reicht", async () => {
// Das bedingte updateMany trifft keine Zeile mehr — jemand anders war
// schneller.
db.inventory.updateMany.mockResolvedValue({ count: 0 });
const response = await post();
expect(response.status).toBe(409);
expect(sessionsCreate).not.toHaveBeenCalled();
});
});
describe("Normalfall", () => {
test("legt die Bestellung an, oeffnet eine Stripe-Sitzung und merkt sich deren Kennung", async () => {
const response = await post();
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
url: "https://checkout.stripe.com/c/pay/cs_test_1",
});
expect(db.order.update).toHaveBeenCalledWith({
where: { id: ORDER.id },
data: { stripeCheckoutSessionId: "cs_test_1" },
});
});
test("begrenzt die Lebensdauer der Sitzung, damit Bestand nicht tagelang blockiert bleibt", async () => {
await post();
const { expires_at: expiresAt } = sessionsCreate.mock.calls[0][0];
const minutes = (expiresAt - Math.floor(Date.now() / 1000)) / 60;
// Stripes Voreinstellung waeren 24 Stunden — damit liesse sich der Shop
// mit wenigen Klicks leerraeumen, ohne einen Cent zu zahlen. Stripes
// Untergrenze sind 30 Minuten.
expect(minutes).toBeGreaterThanOrEqual(30);
expect(minutes).toBeLessThanOrEqual(35);
});
});
describe("Wenn Stripe nicht mitspielt", () => {
test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => {
// Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID
// stehen. Kein Aufraeumweg griff danach — der Abbruch-Klick braucht die
// Sitzungs-ID, der CronJob ebenso, und ein Stripe-Ereignis kann es ohne
// Sitzung nicht geben. Der reservierte Bestand war dauerhaft verloren.
sessionsCreate.mockRejectedValue(new Error("Invalid API Key provided: sk_live_***"));
releaseUnpaidOrder.mockResolvedValue({
orderNumber: ORDER.orderNumber,
email: validPayload.email,
firstName: validPayload.firstName,
locale: "de",
});
const response = await post();
const body = await response.json();
expect(response.status).toBe(502);
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
expect(db.order.update).toHaveBeenCalledWith({
where: { id: ORDER.id },
data: { notes: expect.stringContaining("Automatisch storniert") },
});
// Ohne orderNumber: mit ihr schickte die Oberflaeche frueher auf die
// Erfolgsseite ("wir melden uns") — eine Zusage, die niemand einloesen
// konnte.
expect(body).not.toHaveProperty("orderNumber");
// Die Stripe-Meldung darf nicht nach aussen: dieser Endpunkt ist ohne
// Anmeldung erreichbar und sie kann Kontodetails enthalten.
expect(body.error).not.toContain("sk_live");
});
test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => {
// Sonst entstuende genau der Zustand, den es nicht geben darf: eine
// Bestellung auf "NEU" ohne Sitzungs-ID. Die Sitzung darf dabei
// gefahrlos verfallen — ihre URL steht nur in der Antwort, die wir
// daraufhin nicht ausliefern.
db.order.update.mockRejectedValueOnce(new Error("connection reset"));
releaseUnpaidOrder.mockResolvedValue({
orderNumber: ORDER.orderNumber,
email: validPayload.email,
firstName: validPayload.firstName,
locale: "de",
});
const response = await post();
expect(response.status).toBe(502);
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
});
});
});
+95 -13
View File
@@ -12,6 +12,7 @@ import { verifyTurnstileToken } from "@/lib/turnstile";
import { getShippingCostCents } from "@/lib/shipping"; import { getShippingCostCents } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat"; import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -30,6 +31,24 @@ class DiscountCodeExhaustedError extends Error {}
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall. // ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
const SERIALIZATION_CONFLICT_CODE = "P2034"; const SERIALIZATION_CONFLICT_CODE = "P2034";
/**
* Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24
* Stunden zu und nimmt ohne Angabe die 24 Stunden.
*
* Das ist hier die falsche Voreinstellung: die Bestellung reserviert beim
* Anlegen Lagerbestand, und erst wenn Stripe die Sitzung fuer abgelaufen
* erklaert, kommt dieser Bestand zurueck (checkout.session.expired, siehe
* api/checkout/webhook). Mit 24 Stunden haelt jeder nicht zu Ende gefuehrte
* Checkout Ware einen Tag lang fest — und wer das absichtlich tut, kann den
* Shop mit wenigen Klicks leerraeumen, ohne einen Cent zu zahlen.
*
* 32 statt 30 Minuten wegen der Untergrenze: zwischen dieser Zeile und dem
* Eintreffen der Anfrage bei Stripe vergehen Sekundenbruchteile, und die
* Uhren muessen nicht exakt gleich gehen. Genau 30 Minuten koennten deshalb
* als "weniger als 30" ankommen und die Sitzungserstellung scheitern lassen.
*/
const CHECKOUT_SESSION_TTL_MINUTES = 32;
function generateOrderNumber(): string { function generateOrderNumber(): string {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und // Kryptografisch zufaellig statt Math.random(): nicht erratbar und
@@ -237,6 +256,13 @@ export async function POST(request: Request) {
const locale = await getLocale(); const locale = await getLocale();
// Steht gleich ausschliesslich in der cancel_url der Stripe-Sitzung und
// gibt den reservierten Bestand frei, sobald die Kundschaft dort abbricht
// (siehe checkout/cancel). 32 Byte, damit er nicht durchprobiert werden
// kann — mit einem ratbaren Wert liesse sich die offene Zahlung fremder
// Leute abbrechen.
const cancelToken = randomBytes(32).toString("hex");
let order; let order;
try { try {
// Bestellanlage und Bestandsreservierung atomar: das bedingte // Bestellanlage und Bestandsreservierung atomar: das bedingte
@@ -278,6 +304,7 @@ export async function POST(request: Request) {
return tx.order.create({ return tx.order.create({
data: { data: {
orderNumber: generateOrderNumber(), orderNumber: generateOrderNumber(),
cancelToken,
customerId: customer?.id, customerId: customer?.id,
locale, locale,
fulfillmentMethod: input.fulfillmentMethod, fulfillmentMethod: input.fulfillmentMethod,
@@ -396,18 +423,31 @@ export async function POST(request: Request) {
: []), : []),
], ],
...(discounts ? { discounts } : {}), ...(discounts ? { discounts } : {}),
// Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und
// haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES).
expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60,
success_url: `${origin}/checkout/success?order=${order.orderNumber}`, success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
cancel_url: `${origin}/checkout/cancel`, // Der Token statt der Bestellnummer: die Bestellnummer steht in jeder
// Bestaetigungsmail und auf jeder Rechnung, mit ihr liesse sich also
// die noch offene Zahlung einer fremden Person abbrechen.
cancel_url: `${origin}/checkout/cancel?ref=${cancelToken}`,
metadata: { orderId: order.id, orderNumber: order.orderNumber }, metadata: { orderId: order.id, orderNumber: order.orderNumber },
}); });
// Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad // KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die
// ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber // Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick
// session.metadata.orderId, nicht ueber dieses Feld) — schlaegt nur // (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der
// dieser Schreibzugriff fehl, waere es falsch, die bereits erstellte, // Aufraeumlauf (scripts/release-stale-orders.ts) findet ohne sie keine
// echte und bezahlbare Stripe-Session deswegen zu verwerfen und der // Sitzung, deren Ablauf er pruefen koennte. Die Bestellung bliebe als
// Kundschaft einen Fehler zu zeigen (Risiko: Retry legt eine zweite // "NEU" stehen und haette den reservierten Lagerbestand dauerhaft
// Bestellung samt zweiter Bestandsreservierung an). // blockiert — unbemerkt, weil sie im Admin-Bereich wie jede andere
// offene Bestellung aussieht.
//
// Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung
// sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres
// catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos:
// ihre URL steht ausschliesslich in der Antwort, die wir daraufhin NICHT
// ausliefern — niemand ausser diesem Prozess hat sie je gesehen.
try { try {
await prisma.order.update({ await prisma.order.update({
where: { id: order.id }, where: { id: order.id },
@@ -415,25 +455,67 @@ export async function POST(request: Request) {
}); });
} catch (error: unknown) { } catch (error: unknown) {
console.error( console.error(
`Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen (Session ${session.id} bleibt trotzdem gueltig):`, `Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen — Session ${session.id} wird geschlossen und die Bestellung aufgeloest:`,
error error
); );
// Eigenes try/catch: scheitert auch das Schliessen, bleibt der
// urspruengliche Fehler der wichtigere. Die Sitzung laeuft dann
// spaetestens nach CHECKOUT_SESSION_TTL_MINUTES von selbst ab.
try {
await stripe.checkout.sessions.expire(session.id);
} catch (expireError: unknown) {
console.error(`Schliessen der Session ${session.id} fehlgeschlagen:`, expireError);
}
throw error;
} }
return NextResponse.json({ url: session.url }); return NextResponse.json({ url: session.url });
} catch (error: unknown) { } catch (error: unknown) {
// Bestellung bleibt als "NEU" erhalten, damit sie im Admin-Bereich manuell
// nachbearbeitet werden kann, auch wenn Stripe (noch) nicht konfiguriert ist.
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client // Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten // zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
// und dieser Endpunkt ist ohne Authentifizierung erreichbar. // und dieser Endpunkt ist ohne Authentifizierung erreichbar.
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message); console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message);
// Diese Bestellung ist nie bezahlbar geworden: es gibt keine Sitzung, zu
// der die Kundschaft geschickt werden koennte, und damit auch kein
// Ereignis von Stripe, das den reservierten Bestand je wieder freigeben
// wuerde. Er muss deshalb hier zurueck — sonst fehlt die Ware dauerhaft
// im Bestand, ohne dass irgendwo ein Fehler sichtbar waere.
//
// "cancel" statt "delete": die Bestellung bleibt als "STORNIERT" mit
// Notiz stehen. Anders als ein weggeklickter Warenkorb ist das ein
// Vorgang, dem die Imkerei nachgehen will — hier wollte jemand kaufen
// und ist an der Zahlungsanbindung gescheitert.
try {
const released = await releaseUnpaidOrder(order.id, "cancel");
if (released) {
await prisma.order.update({
where: { id: order.id },
data: {
notes:
"Automatisch storniert: Die Stripe-Zahlungssitzung konnte nicht erstellt werden, die Bestellung war nie bezahlbar. Der Lagerbestand wurde zurueckgebucht.",
},
});
}
} catch (releaseError: unknown) {
// Laut, aber nicht weiterwerfen: die Kundschaft bekommt unten ohnehin
// eine Fehlermeldung, und ein zusaetzlicher 500er wuerde nur die
// Ursache verdecken. Der Aufraeumlauf greift als zweites Netz.
console.error(
`Bestellung ${order.orderNumber} konnte nach dem Stripe-Fehler nicht aufgeloest werden — Lagerbestand bleibt vorerst reserviert:`,
releaseError
);
}
// Bewusst OHNE orderNumber: frueher schickte die Oberflaeche daraufhin auf
// die Erfolgsseite ("wir melden uns"). Das war eine Zusage, die niemand
// einloesen konnte — es gab keine Sitzung, keinen Zahlungsweg und keine
// Moeglichkeit, die Bestellung nachtraeglich zu bezahlen.
return NextResponse.json( return NextResponse.json(
{ {
error: error:
"Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.", "Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.",
orderNumber: order.orderNumber,
}, },
{ status: 502 } { status: 502 }
); );
+394
View File
@@ -0,0 +1,394 @@
/**
* Tests fuer den Stripe-Webhook — die Stelle, an der Geld in den Shop kommt.
*
* Hier entscheidet sich, ob eine Bestellung als bezahlt gilt, ob eine Rechnung
* mit fortlaufender Nummer entsteht und ob ein Gutschein als eingeloest zaehlt.
* Ein Fehler faellt an dieser Stelle im Betrieb praktisch nicht auf: Stripe
* quittiert brav, die Kundschaft hat bezahlt, und erst Wochen spaeter fehlt
* eine Rechnung oder ein Gutschein wurde doppelt gezaehlt.
*
* Datenbank und Stripe sind vollstaendig ersetzt. Geprueft wird nicht, ob
* Prisma funktioniert, sondern ob dieser Handler unter den Bedingungen, die im
* Betrieb tatsaechlich vorkommen, das Richtige tut — allen voran bei mehrfach
* zugestellten Ereignissen, die Stripe ausdruecklich vorsieht.
*/
import { describe, test, expect, beforeEach, vi } from "vitest";
const constructEvent = vi.fn();
vi.mock("@/lib/stripe", () => ({
getStripeClient: () => ({
webhooks: { constructEvent },
}),
}));
const sendMail = vi.fn();
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...args) }));
vi.mock("@/lib/email-templates", () => ({
orderConfirmationEmail: vi.fn(async () => ({
subject: "Bestellbestaetigung",
html: "<p>ok</p>",
text: "ok",
})),
paymentFailedEmail: vi.fn(async () => ({
subject: "Zahlung fehlgeschlagen",
html: "<p>fehlgeschlagen</p>",
text: "fehlgeschlagen",
})),
}));
const releaseUnpaidOrder = vi.fn();
vi.mock("@/lib/order-release", () => ({
releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args),
}));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
// Rechnungs-PDF: die Erzeugung selbst hat eigene Tests (invoice-pdf,
// invoice-storage). Hier zaehlt nur, dass sie angestossen wird und dass das
// erzeugte Dokument an der Bestaetigungsmail haengt.
const getInvoiceByNumber = vi.fn();
vi.mock("@/lib/account-data", () => ({
getInvoiceByNumber: (...args: unknown[]) => getInvoiceByNumber(...args),
}));
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) }));
vi.mock("@/lib/invoice-storage", () => ({
invoiceRelativePath: vi.fn(() => "2026/mustermann-erika/RE-2026-0001.pdf"),
storeInvoicePdf: vi.fn(async () => undefined),
}));
/**
* Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx`
* weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und
* die laesst sich in einem Unit-Test ohnehin nicht nachbilden. Geprueft wird
* hier die Ablauflogik, nicht das Sperrverhalten.
*/
const db = {
order: {
updateMany: vi.fn(),
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
discountCode: { update: vi.fn() },
payment: { upsert: vi.fn(), findFirst: vi.fn(), update: vi.fn() },
invoice: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
invoiceCounter: { upsert: vi.fn() },
$transaction: vi.fn(),
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
const { POST } = await import("./route");
const ORDER_ID = "order_1";
const paidOrder = {
id: ORDER_ID,
orderNumber: "WH-2026-AABBCCDD",
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
locale: "de",
fulfillmentMethod: "VERSAND",
street: "Wiesenweg 1",
zip: "86450",
city: "Altenmuenster",
giftMessage: null,
subtotalCents: 1980,
shippingCents: 700,
discountCents: 0,
totalCents: 2680,
discountCodeId: null as string | null,
items: [{ productName: "Fruehtracht 2026", quantity: 2, weightGrams: 500, totalCents: 1980 }],
};
function session(overrides: Record<string, unknown> = {}) {
return {
id: "cs_test_1",
payment_status: "paid",
payment_intent: "pi_test_1",
amount_total: paidOrder.totalCents,
metadata: { orderId: ORDER_ID, orderNumber: paidOrder.orderNumber },
...overrides,
};
}
function post(): Promise<Response> {
return POST(
new Request("https://wabenhain.test/api/checkout/webhook", {
method: "POST",
headers: { "stripe-signature": "sig" },
body: "{}",
})
);
}
/** Stellt den Normalfall her: Bestellung steht auf "NEU" und wird beansprucht. */
function givenUnpaidOrderIsClaimed(order = paidOrder): void {
db.order.updateMany.mockResolvedValue({ count: 1 });
db.order.findUniqueOrThrow.mockResolvedValue(order);
db.invoice.findUnique.mockResolvedValue(null);
db.invoiceCounter.upsert.mockResolvedValue({ lastNumber: 1 });
db.invoice.create.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0001" });
getInvoiceByNumber.mockResolvedValue({
id: "inv_1",
invoiceNumber: "RE-2026-0001",
issuedAt: new Date("2026-01-15T10:30:00.000Z"),
totalCents: order.totalCents,
order: { ...order, locale: "de" },
});
}
beforeEach(() => {
vi.clearAllMocks();
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
db.$transaction.mockImplementation(async (arg: unknown) =>
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg
);
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
});
describe("Stripe-Webhook", () => {
test("weist eine Anfrage mit ungueltiger Signatur ab, ohne irgendetwas zu schreiben", async () => {
constructEvent.mockImplementation(() => {
throw new Error("No signatures found matching the expected signature for payload");
});
const response = await post();
expect(response.status).toBe(400);
expect(db.order.updateMany).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
});
test("antwortet mit 500, solange STRIPE_WEBHOOK_SECRET fehlt", async () => {
delete process.env.STRIPE_WEBHOOK_SECRET;
const response = await post();
expect(response.status).toBe(500);
expect(constructEvent).not.toHaveBeenCalled();
});
test('markiert eine Bestellung bei "completed" NICHT als bezahlt, solange payment_status "unpaid" ist', async () => {
// SEPA-Lastschrift: die Sitzung ist abgeschlossen, das Geld aber noch nicht
// eingezogen. Die Bestaetigung kommt spaeter als eigenes Ereignis.
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session({ payment_status: "unpaid" }) },
});
await post();
expect(db.order.updateMany).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
});
test("markiert die Bestellung als bezahlt, legt eine Rechnung an und verschickt die Bestaetigung", async () => {
givenUnpaidOrderIsClaimed();
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session() },
});
await post();
expect(db.order.updateMany).toHaveBeenCalledWith({
where: { id: ORDER_ID, status: "NEU" },
data: { status: "BEZAHLT" },
});
expect(db.payment.upsert).toHaveBeenCalledOnce();
expect(db.invoice.create).toHaveBeenCalledOnce();
expect(sendMail).toHaveBeenCalledOnce();
// Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein
// Konto, in dem sie sonst zu finden waere.
expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf");
});
test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => {
const withCode = { ...paidOrder, discountCodeId: "code_1" };
givenUnpaidOrderIsClaimed(withCode);
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session() },
});
await post();
expect(db.discountCode.update).toHaveBeenCalledOnce();
// Zweite Zustellung desselben Ereignisses: die Bestellung steht bereits auf
// "BEZAHLT", der Anspruch geht ins Leere (count 0).
db.order.updateMany.mockResolvedValue({ count: 0 });
db.order.findUnique.mockResolvedValue({ status: "BEZAHLT", orderNumber: withCode.orderNumber });
db.invoice.findUnique.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0001" });
await post();
// Weiterhin genau ein Mal — Payment, Rechnung und Mail duerfen nachgeholt
// werden, der Gutscheinzaehler aber nicht erneut erhoeht. Auch die
// Rechnungsnummer wird nicht ein zweites Mal gezogen: eine Luecke in der
// Nummernfolge ist genau das, was § 14 Abs. 4 Nr. 4 UStG ausschliesst.
expect(db.discountCode.update).toHaveBeenCalledOnce();
expect(db.invoiceCounter.upsert).toHaveBeenCalledOnce();
});
test("liefert nichts aus und meldet laut, wenn eine Zahlung zu einer stornierten Bestellung eintrifft", async () => {
// Regression: frueher ein stilles "return null". Das Geld war eingezogen,
// im Shop entstand aber weder Rechnung noch Bestaetigung noch ein
// Logeintrag — das faellt niemandem auf, bis sich die Kundschaft meldet.
db.order.updateMany.mockResolvedValue({ count: 0 });
db.order.findUnique.mockResolvedValue({
status: "STORNIERT",
orderNumber: paidOrder.orderNumber,
});
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session() },
});
await post();
expect(db.invoice.create).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(`Bestellung ${paidOrder.orderNumber}, die auf "STORNIERT" steht`)
);
});
test("meldet eine Betragsabweichung zwischen berechneter und eingezogener Summe", async () => {
givenUnpaidOrderIsClaimed();
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session({ amount_total: 2000 }) },
});
await post();
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Betragsabweichung"));
// Das Geld ist geflossen — die Bestellung bleibt trotzdem bezahlt.
expect(db.payment.upsert).toHaveBeenCalledOnce();
});
test("loescht eine abgelaufene, nie bezahlte Bestellung und schickt dazu KEINE Mail", async () => {
releaseUnpaidOrder.mockResolvedValue({
orderNumber: paidOrder.orderNumber,
email: paidOrder.email,
firstName: paidOrder.firstName,
locale: "de",
});
constructEvent.mockReturnValue({
type: "checkout.session.expired",
data: { object: session({ payment_status: "unpaid" }) },
});
await post();
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER_ID, "delete", paidOrder.totalCents);
// Eine Mail zu einem Vorgang, den man selbst abgebrochen hat, verwirrt
// mehr als sie hilft.
expect(sendMail).not.toHaveBeenCalled();
});
test("storniert bei fehlgeschlagenem Einzug und benachrichtigt die Kundschaft", async () => {
releaseUnpaidOrder.mockResolvedValue({
orderNumber: paidOrder.orderNumber,
email: paidOrder.email,
firstName: paidOrder.firstName,
locale: "de",
});
constructEvent.mockReturnValue({
type: "checkout.session.async_payment_failed",
data: { object: session({ payment_status: "unpaid", amount_total: 2680 }) },
});
await post();
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER_ID, "cancel", 2680);
// Hier wurde tatsaechlich gezahlt und es ist gescheitert — jemand wartet
// auf Ware und muss erfahren, dass daraus nichts wird.
expect(sendMail).toHaveBeenCalledOnce();
});
describe("Geld, das den Shop wieder verlaesst", () => {
test("setzt die Zahlung bei vollstaendiger Erstattung auf REFUNDED", async () => {
db.payment.findFirst.mockResolvedValue({
id: "pay_1",
status: "SUCCEEDED",
order: { orderNumber: paidOrder.orderNumber },
});
constructEvent.mockReturnValue({
type: "charge.refunded",
data: {
object: {
payment_intent: "pi_test_1",
refunded: true,
amount: 2680,
amount_refunded: 2680,
},
},
});
await post();
expect(db.payment.update).toHaveBeenCalledWith({
where: { id: "pay_1" },
data: { status: "REFUNDED" },
});
});
test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => {
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
// wuerde eine Auswertung den ganzen Umsatz abschreiben.
db.payment.findFirst.mockResolvedValue({
id: "pay_1",
status: "SUCCEEDED",
order: { orderNumber: paidOrder.orderNumber },
});
constructEvent.mockReturnValue({
type: "charge.refunded",
data: {
object: {
payment_intent: "pi_test_1",
refunded: false,
amount: 2680,
amount_refunded: 500,
},
},
});
await post();
expect(db.payment.update).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung"));
});
test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => {
db.payment.findFirst.mockResolvedValue({
orderId: ORDER_ID,
order: { orderNumber: paidOrder.orderNumber, notes: "Kundenwunsch: bitte klingeln" },
});
constructEvent.mockReturnValue({
type: "charge.dispute.created",
data: {
object: {
id: "dp_test_1",
payment_intent: "pi_test_1",
amount: 2680,
reason: "fraudulent",
},
},
});
await post();
const written = db.order.update.mock.calls[0][0].data.notes as string;
expect(written).toContain("Kundenwunsch: bitte klingeln");
expect(written).toContain("dp_test_1");
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("RUECKBUCHUNG"));
});
});
});
+215 -53
View File
@@ -1,14 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { getStripeClient } from "@/lib/stripe"; import { getStripeClient } from "@/lib/stripe";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates"; import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
import { restockOrderItems } from "@/lib/inventory"; import { releaseUnpaidOrder, type ReleaseMode } from "@/lib/order-release";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { getInvoiceByNumber } from "@/lib/account-data"; import { getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage"; import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { formatInvoiceNumber } from "@/lib/invoice-number";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type Stripe from "stripe"; import type Stripe from "stripe";
@@ -62,9 +62,14 @@ async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | nul
/** /**
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf * Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
* /api/checkout/webhook mit den Events "checkout.session.completed", * /api/checkout/webhook mit den Events "checkout.session.completed",
* "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed" * "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed",
* und "checkout.session.expired" angelegt und STRIPE_WEBHOOK_SECRET gesetzt * "checkout.session.expired", "charge.refunded" und "charge.dispute.created"
* werden. Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter * angelegt und STRIPE_WEBHOOK_SECRET gesetzt
* werden. Die beiden letzten betreffen Geld, das den Shop wieder verlaesst,
* ohne dass hier jemand etwas angeklickt haette — eine Erstattung von Hand im
* Stripe-Dashboard und eine Rueckbuchung durch die Bank. Beide waren bisher
* ausschliesslich im Stripe-Dashboard zu sehen (siehe recordRefund und
* recordDispute). Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter
* Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die * Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die
* Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht * Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht
* (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass * (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass
@@ -113,17 +118,139 @@ export async function POST(request: Request) {
if (event.type === "checkout.session.async_payment_failed") { if (event.type === "checkout.session.async_payment_failed") {
const session = event.data.object as Stripe.Checkout.Session; const session = event.data.object as Stripe.Checkout.Session;
await failOrder(session); // Hier wurde tatsaechlich eine Zahlung versucht und ist gescheitert. Die
// Kundschaft hat den Checkout abgeschlossen und rechnet mit einer
// Lieferung — das gehoert nachvollziehbar festgehalten und beantwortet.
await failOrder(session, "cancel");
} }
if (event.type === "checkout.session.expired") { if (event.type === "checkout.session.expired") {
const session = event.data.object as Stripe.Checkout.Session; const session = event.data.object as Stripe.Checkout.Session;
await failOrder(session); // Hier wurde nie bezahlt und nie etwas bestaetigt: jemand hat den
// Checkout stehen lassen. Die Bestellung verschwindet spurlos, statt als
// "STORNIERT" im Admin-Bereich wie ein gescheiterter Verkauf auszusehen.
// Ohne Zahlung gibt es keine Nachricht darueber: eine Mail zu einem
// Vorgang, den man selbst abgebrochen hat, verwirrt mehr als sie hilft.
await failOrder(session, "delete");
}
if (event.type === "charge.refunded") {
await recordRefund(event.data.object as Stripe.Charge);
}
if (event.type === "charge.dispute.created") {
await recordDispute(event.data.object as Stripe.Dispute);
} }
return NextResponse.json({ received: true }); return NextResponse.json({ received: true });
} }
/** Die PaymentIntent-ID eines Stripe-Objekts, gleich ob sie als ID oder als eingebettetes Objekt kommt. */
function paymentIntentIdOf(value: string | { id: string } | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
/**
* Haelt eine Erstattung fest, die AUSSERHALB dieser Anwendung ausgeloest
* wurde — typischerweise von Hand im Stripe-Dashboard.
*
* Der Weg ueber den Retouren-Bereich und die Stornierung im Admin-Bereich
* schreiben den Zahlungsstatus selbst mit. Eine Erstattung direkt bei Stripe
* tat das bisher nicht: die Zahlung stand im Shop weiter auf "SUCCEEDED", und
* jede spaetere Auswertung zaehlte einen Umsatz mit, den es nicht mehr gab.
*
* Der Bestellstatus bleibt unangetastet. Ob eine Erstattung eine Stornierung
* bedeutet, eine Retoure oder eine Kulanzgutschrift bei gelieferter Ware,
* steht an diesem Ereignis nicht — und Ware automatisch in den Bestand
* zurueckzubuchen, die vielleicht nie zurueckkommt, waere schlimmer als sie
* von Hand einzubuchen.
*/
async function recordRefund(charge: Stripe.Charge): Promise<void> {
const paymentIntentId = paymentIntentIdOf(charge.payment_intent);
if (!paymentIntentId) return;
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { id: true, status: true, order: { select: { orderNumber: true } } },
});
if (!payment) {
console.warn(
`[checkout] Erstattung zu PaymentIntent ${paymentIntentId} erhalten, zu der es hier keine Zahlung gibt — nichts zu tun.`
);
return;
}
// "refunded" ist nur bei einer VOLLSTAENDIGEN Erstattung wahr. Eine
// Teilerstattung darf die Zahlung nicht auf "REFUNDED" setzen: der
// Restbetrag ist weiterhin eingezogen, und eine Auswertung wuerde ihn sonst
// ganz abschreiben.
if (!charge.refunded) {
console.warn(
`[checkout] Teilerstattung bei Bestellung ${payment.order.orderNumber}: ${charge.amount_refunded} von ${charge.amount} Cent. Der Zahlungsstatus bleibt "${payment.status}" — bitte die Rechnung von Hand korrigieren.`
);
return;
}
if (payment.status === "REFUNDED") return;
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
console.info(
`[checkout] Bestellung ${payment.order.orderNumber}: vollstaendige Erstattung ueber ${charge.amount_refunded} Cent vermerkt.`
);
}
/**
* Vermerkt eine Rueckbuchung (Chargeback) an der Bestellung.
*
* Eine Rueckbuchung hat eine Frist: Stripe verlangt die Stellungnahme
* innerhalb weniger Tage, sonst gilt sie als verloren. Bisher kam davon im
* Shop nichts an — sie war ausschliesslich im Stripe-Dashboard zu sehen, und
* nur wer dort nachsieht, haette sie rechtzeitig bemerkt.
*
* Automatisch entschieden wird hier nichts: Status und Lagerbestand bleiben,
* wie sie sind. Der Vermerk landet in den Notizen der Bestellung, wo er im
* Admin-Bereich neben allem anderen zu dieser Bestellung steht.
*/
async function recordDispute(dispute: Stripe.Dispute): Promise<void> {
const paymentIntentId = paymentIntentIdOf(dispute.payment_intent);
if (!paymentIntentId) return;
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { orderId: true, order: { select: { orderNumber: true, notes: true } } },
});
if (!payment) {
console.error(
`[checkout] RUECKBUCHUNG zu PaymentIntent ${paymentIntentId} erhalten, zu der es hier keine Zahlung gibt (Dispute ${dispute.id}). Bitte im Stripe-Dashboard pruefen.`
);
return;
}
console.error(
`[checkout] RUECKBUCHUNG bei Bestellung ${payment.order.orderNumber}: ${dispute.amount} Cent, Grund "${dispute.reason}" (Dispute ${dispute.id}). Fristgebundene Stellungnahme im Stripe-Dashboard noetig.`
);
const note = `[${new Date().toISOString()}] Rueckbuchung ueber ${dispute.amount} Cent, Grund "${dispute.reason}" (Stripe-Dispute ${dispute.id}). Stellungnahme im Stripe-Dashboard ist fristgebunden.`;
try {
await prisma.order.update({
where: { id: payment.orderId },
// Anhaengen statt ersetzen: bestehende Notizen der Imkerei duerfen
// dabei nicht verlorengehen.
data: { notes: payment.order.notes ? `${payment.order.notes}\n${note}` : note },
});
} catch (error: unknown) {
// Nicht weiterwerfen: der Logeintrag oben ist die wichtigere Meldung, und
// ein 500er wuerde Stripe nur zu Wiederholungen veranlassen, die denselben
// Vermerk erneut anhaengen.
console.error(
`[checkout] Rueckbuchungs-Vermerk an Bestellung ${payment.order.orderNumber} konnte nicht gespeichert werden:`,
error
);
}
}
async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> { async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
const orderId = session.metadata?.orderId; const orderId = session.metadata?.orderId;
if (!orderId) return; if (!orderId) return;
@@ -156,9 +283,29 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
if (!firstTime) { if (!firstTime) {
// Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet // Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet
// (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus // (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus
// (z.B. "STORNIERT") — im zweiten Fall nichts weiter tun. // (z.B. "STORNIERT").
const existing = await tx.order.findUnique({ where: { id: orderId }, select: { status: true } }); const existing = await tx.order.findUnique({
if (!existing || existing.status !== "BEZAHLT") return null; where: { id: orderId },
select: { status: true, orderNumber: true },
});
if (!existing) {
// Die Bestellung ist weg, das Geld ist da. Moeglich, wenn eine als
// abgelaufen aufgeloeste Bestellung danach doch noch bezahlt wurde.
console.error(
`[checkout] Zahlung eingegangen fuer eine nicht mehr vorhandene Bestellung (Order ${orderId}, Session ${session.id}). Der Betrag liegt bei Stripe und muss von Hand erstattet oder zugeordnet werden.`
);
return null;
}
if (existing.status !== "BEZAHLT") {
// Frueher ein stilles "return null": die Zahlung war eingezogen, im
// Shop entstand aber weder Rechnung noch Bestaetigung noch ein
// Logeintrag. Genau der Fall, der niemandem auffaellt, bis sich die
// Kundschaft meldet.
console.error(
`[checkout] Zahlung eingegangen fuer Bestellung ${existing.orderNumber}, die auf "${existing.status}" steht (Session ${session.id}). Es wird nichts ausgeliefert und nichts berechnet — bitte pruefen und gegebenenfalls erstatten.`
);
return null;
}
} }
const updated = await tx.order.findUniqueOrThrow({ const updated = await tx.order.findUniqueOrThrow({
@@ -183,6 +330,26 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
// (z.B. bereits storniert) — nichts weiter zu tun. // (z.B. bereits storniert) — nichts weiter zu tun.
if (!order) return; if (!order) return;
// Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe
// tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle
// (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt
// als Coupon), sie MUESSEN uebereinstimmen. Tun sie es nicht, hat etwas
// ausserhalb dieses Codes mitgerechnet — am wahrscheinlichsten eine im
// Stripe-Dashboard eingeschaltete automatische Steuer oder eine dort
// gepflegte Versandoption.
//
// Die Bestellung bleibt trotzdem bezahlt, denn das Geld ist geflossen; sie
// hier abzulehnen wuerde eine echte Zahlung ins Leere laufen lassen. Aber
// die Abweichung muss auffallen: bei zu wenig eingezogenem Geld wird sonst
// stillschweigend mit Verlust versendet, bei zu viel weicht die Rechnung
// vom tatsaechlich belasteten Betrag ab — und die Rechnung ist ein
// Steuerdokument.
if (session.amount_total !== null && session.amount_total !== order.totalCents) {
console.error(
`[checkout] Betragsabweichung bei Bestellung ${order.orderNumber}: berechnet ${order.totalCents} Cent, von Stripe eingezogen ${session.amount_total} Cent (Session ${session.id}). Bitte pruefen, bevor die Ware das Haus verlaesst.`
);
}
try { try {
await prisma.payment.upsert({ await prisma.payment.upsert({
where: { orderId: order.id }, where: { orderId: order.id },
@@ -200,18 +367,39 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
}, },
}); });
const invoice = await prisma.invoice.upsert({ // Rechnungsnummer und Rechnung in EINER Transaktion: die Zeilensperre auf
where: { orderId: order.id }, // dem Jahreszaehler haelt eine zweite, gleichzeitig bezahlte Bestellung so
create: { // lange auf, bis diese hier festgeschrieben ist. Ohne das laesen beide
// denselben Stand und bekaemen dieselbe Nummer.
//
// Der Zaehler wird NUR beim tatsaechlichen Anlegen hochgezaehlt. Stripe
// stellt denselben Webhook mehrfach zu; ein blindes Hochzaehlen vor der
// Existenzpruefung risse bei jedem Wiederholungsversuch eine Luecke in die
// Nummernfolge — und eine Luecke ist genau das, was § 14 Abs. 4 Nr. 4 UStG
// ausschliessen soll.
const invoice = await prisma.$transaction(async (tx) => {
const existing = await tx.invoice.findUnique({ where: { orderId: order.id } });
if (existing) return existing;
const year = new Date().getFullYear();
const counter = await tx.invoiceCounter.upsert({
where: { year },
create: { year, lastNumber: 1 },
update: { lastNumber: { increment: 1 } },
select: { lastNumber: true },
});
return tx.invoice.create({
data: {
orderId: order.id, orderId: order.id,
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`, invoiceNumber: formatInvoiceNumber(year, counter.lastNumber),
totalCents: order.totalCents, totalCents: order.totalCents,
// Kein Steuersatz an der Rechnung: er haengt an der einzelnen // Kein Steuersatz an der Rechnung: er haengt an der einzelnen
// Position, weil eine Bestellung Waren mit verschiedenen Saetzen // Position, weil eine Bestellung Waren mit verschiedenen Saetzen
// enthalten kann (Honig 7%, Bienenwachskerze 19%). Festgeschrieben // enthalten kann (Honig 7%, Bienenwachskerze 19%). Festgeschrieben
// wird er beim Anlegen der Bestellung (api/checkout/route.ts). // wird er beim Anlegen der Bestellung (api/checkout/route.ts).
}, },
update: {}, });
}); });
const invoicePdf = await storeInvoiceDocument(invoice.invoiceNumber); const invoicePdf = await storeInvoiceDocument(invoice.invoiceNumber);
@@ -272,48 +460,22 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
} }
} }
async function failOrder(session: Stripe.Checkout.Session): Promise<void> { async function failOrder(session: Stripe.Checkout.Session, mode: ReleaseMode): Promise<void> {
const orderId = session.metadata?.orderId; const orderId = session.metadata?.orderId;
if (!orderId) return; if (!orderId) return;
const result = await prisma.$transaction(async (tx) => { // Bestand zurueckbuchen und die Bestellung aufloesen liegt in
// Atomarer Claim (gleiches Muster wie oben bei fulfillOrder): ein // lib/order-release.ts, weil derselbe Vorgang auch beim Abbruch durch die
// getrenntes findUnique+update wuerde bei zwei parallel zugestellten // Kundschaft (checkout/cancel) und im Aufraeumlauf
// "expired"/"async_payment_failed"-Webhooks fuer dieselbe Bestellung // (scripts/release-stale-orders.ts) gebraucht wird. Kommt der Webhook nach
// beide Kopien noch "NEU" lesen lassen, bevor eine von ihnen schreibt — // einem dieser beiden an, liefert der Helfer null — die Bestellung ist dann
// restockOrderItems liefe dann zweimal und der Lagerbestand wuerde // nicht mehr "NEU" und der Bestand laengst zurueckgebucht.
// stillschweigend doppelt gutgeschrieben. const result = await releaseUnpaidOrder(orderId, mode, session.amount_total ?? 0);
const claim = await tx.order.updateMany({ if (!result) return;
where: { id: orderId, status: "NEU" },
data: { status: "STORNIERT" },
});
if (claim.count === 0) return null;
const previous = await tx.order.findUniqueOrThrow({ // Nur beim tatsaechlich fehlgeschlagenen Einzug: dort wartet jemand auf
where: { id: orderId }, // Ware und muss erfahren, dass daraus nichts wird.
select: { if (mode === "cancel") {
orderNumber: true,
email: true,
firstName: true,
locale: true,
items: { select: { productId: true, quantity: true } },
},
});
// Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie
// bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts).
await restockOrderItems(tx, previous.items);
await tx.payment.upsert({
where: { orderId },
create: { orderId, status: "FAILED", amountCents: session.amount_total ?? 0 },
update: { status: "FAILED" },
});
return previous;
});
if (result) {
const { subject, html, text } = await paymentFailedEmail({ const { subject, html, text } = await paymentFailedEmail({
locale: isLocale(result.locale) ? result.locale : defaultLocale, locale: isLocale(result.locale) ? result.locale : defaultLocale,
orderNumber: result.orderNumber, orderNumber: result.orderNumber,
+114 -2
View File
@@ -1,12 +1,121 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Container, SectionHeading, LinkButton } from "@/components/ui"; import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { releaseUnpaidOrder } from "@/lib/order-release";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Bestellung abgebrochen" }; export const metadata: Metadata = { title: "Bestellung abgebrochen", robots: { index: false } };
// Diese Seite aendert Daten (siehe releaseCheckout) und darf deshalb nie aus
// einem Cache beantwortet werden.
export const dynamic = "force-dynamic";
export default async function CheckoutCancelPage() { const CANCEL_ATTEMPTS_PER_WINDOW = 20;
const CANCEL_WINDOW_MS = 15 * 60 * 1000;
/**
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn
* die Kundschaft die Zahlung bei Stripe abbricht.
*
* Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die
* Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe
* api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe
* Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die
* Person gerade ausdruecklich abgebrochen hat.
*
* Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst
* etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits
* bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden —
* etwa wenn jemand nach erfolgreicher Zahlung den Zurueck-Knopf benutzt und
* dabei wieder auf dieser Seite landet.
*/
async function releaseCheckout(cancelToken: string): Promise<void> {
// Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist
// trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest.
const ip = await getClientIp();
if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return;
const order = await prisma.order.findUnique({
where: { cancelToken },
select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true },
});
// Unbekannter Token oder die Bestellung ist laengst nicht mehr offen
// (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne
// unterscheidbare Rueckmeldung nach aussen: die Seite sieht in jedem Fall
// gleich aus und verraet damit nicht, ob es den Token gibt.
if (!order || order.status !== "NEU") return;
if (!order.stripeCheckoutSessionId) {
// Kann vorkommen, wenn das Speichern der Sitzungs-ID fehlgeschlagen ist
// (best-effort, siehe api/checkout/route.ts). Ohne sie laesst sich bei
// Stripe nicht pruefen, ob doch bezahlt wurde — also nichts zurueckbuchen
// und den Ablauf der Sitzung abwarten, der ueber den Webhook ohnehin
// greift.
console.warn(
`[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst bei Ablauf der Sitzung.`
);
return;
}
try {
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
// "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser
// Seitenaufruf kein Abbruch, sondern ein Zurueck-Klick nach erfolgreicher
// Zahlung. Finger weg.
if (session.status === "complete") return;
// "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit
// niemand die bereits freigegebene Ware im naechsten Moment doch noch
// bezahlt, und erst danach zurueckbuchen.
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
}
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
//
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
// gescheiterter Verkauf.
const released = await releaseUnpaidOrder(order.id, "delete");
if (released) {
console.info(
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
);
}
} catch (error: unknown) {
// Nicht weiterwerfen: die Person hat gerade abgebrochen und soll die
// Abbruchseite sehen, nicht einen Serverfehler. Der Bestand kommt dann
// spaetestens mit dem Ablauf der Sitzung zurueck.
console.error(
`[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`,
error
);
}
}
export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { ref } = await props.searchParams;
if (typeof ref === "string" && ref) {
await releaseCheckout(ref);
// Token aus Adresszeile, Verlauf und Referer nehmen, sobald er seinen
// Zweck erfuellt hat. Er gibt zwar nur genau diese eine, gerade
// abgebrochene Bestellung frei, aber ein Geheimnis gehoert nicht laenger
// sichtbar herum, als es gebraucht wird.
redirect("/checkout/cancel");
}
return ( return (
<div className="py-24"> <div className="py-24">
<Container className="max-w-xl text-center"> <Container className="max-w-xl text-center">
@@ -19,6 +128,9 @@ export default async function CheckoutCancelPage() {
<p className="mt-6 text-ink/70"> <p className="mt-6 text-ink/70">
{t.checkoutCancelPage.text} {t.checkoutCancelPage.text}
</p> </p>
<p className="mt-3 text-sm text-ink/60">
{t.checkoutCancelPage.released}
</p>
<LinkButton href="/warenkorb" className="mt-10"> <LinkButton href="/warenkorb" className="mt-10">
{t.checkoutCancelPage.backToCart} {t.checkoutCancelPage.backToCart}
</LinkButton> </LinkButton>
+50 -3
View File
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, vi } from "vitest"; import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form"; import { CheckoutForm } from "./checkout-form";
import { useCartStore, type CartItem } from "@/store/cart-store"; import { useCartStore, type CartItem } from "@/store/cart-store";
@@ -7,10 +7,15 @@ import { I18nProvider } from "@/i18n/i18n-provider";
import type { ShippingTierLike } from "@/lib/shipping-calc"; import type { ShippingTierLike } from "@/lib/shipping-calc";
import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc"; import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
// Geteilte Instanz statt eines frischen vi.fn() je Aufruf: nur so laesst sich
// pruefen, dass NICHT navigiert wurde.
const routerPush = vi.fn();
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: routerPush }),
})); }));
const fetchMock = vi.fn();
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }]; const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
const bulkDiscountTiers: BulkDiscountTierLike[] = []; const bulkDiscountTiers: BulkDiscountTierLike[] = [];
@@ -36,6 +41,13 @@ function renderCheckout(items: CartItem[]) {
beforeEach(() => { beforeEach(() => {
useCartStore.setState({ items: [], isOpen: false }); useCartStore.setState({ items: [], isOpen: false });
routerPush.mockClear();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
}); });
describe("CheckoutForm", () => { describe("CheckoutForm", () => {
@@ -55,4 +67,39 @@ describe("CheckoutForm", () => {
expect(screen.queryByText("7,00 €")).not.toBeInTheDocument(); expect(screen.queryByText("7,00 €")).not.toBeInTheDocument();
expect(screen.getByText("kostenlos")).toBeInTheDocument(); expect(screen.getByText("kostenlos")).toBeInTheDocument();
}); });
/**
* Regression: Antwortet /api/checkout mit 502 (Stripe nicht erreichbar),
* schickte das Formular frueher auf die Erfolgsseite mit dem Hinweis "wir
* melden uns". Es gab zu dieser Bestellung aber gar keine Zahlungssitzung —
* sie war auf keinem Weg bezahlbar, und die Seite versprach etwas, das
* niemand einloesen konnte.
*/
test("shows the server error instead of the success page when the payment session cannot be created", async () => {
const userEventInstance = userEvent.setup();
const serverError =
"Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.";
fetchMock.mockResolvedValue({
ok: false,
status: 502,
json: async () => ({ error: serverError }),
} as Response);
renderCheckout([item()]);
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1");
await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450");
await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
await waitFor(() => expect(screen.getByText(serverError)).toBeInTheDocument());
// Das Formular navigiert inzwischen gar nicht mehr — diese Zusicherung
// faellt erst dann wieder auf, wenn jemand die Weiterleitung auf die
// Erfolgsseite erneut einbaut. Genau dafuer steht sie hier.
expect(routerPush).not.toHaveBeenCalled();
});
}); });
+5 -6
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react"; import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
import { useRouter } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges"; import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
@@ -61,7 +60,6 @@ export function CheckoutForm({
const hasHydrated = useCartStore((s) => s.hasHydrated); const hasHydrated = useCartStore((s) => s.hasHydrated);
const subtotal = cartTotalCents(items); const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items); const totalQuantity = cartItemCount(items);
const router = useRouter();
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit // Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
// Token-Rueckgabe statt eines versteckten Feldes. // Token-Rueckgabe statt eines versteckten Feldes.
@@ -179,10 +177,11 @@ export function CheckoutForm({
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
if (data.orderNumber) { // Auch der 502er (Stripe nicht erreichbar) landet hier als normale
router.push(`/checkout/success?order=${data.orderNumber}&pending=1`); // Fehlermeldung. Frueher fuehrte er auf die Erfolgsseite mit dem
return; // Hinweis "wir melden uns" — eine Bestellung ohne Zahlungssitzung ist
} // aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas,
// das niemand einloesen konnte (siehe api/checkout/route.ts).
setError(data.error ?? t.checkoutPage.genericError); setError(data.error ?? t.checkoutPage.genericError);
setSubmitting(false); setSubmitting(false);
return; return;
+13
View File
@@ -93,6 +93,19 @@ export async function toggleWishlistAction(formData: FormData): Promise<void> {
if (alreadyWishlisted) { if (alreadyWishlisted) {
await prisma.wishlistItem.deleteMany({ where: { customerId: customer.id, productId } }); await prisma.wishlistItem.deleteMany({ where: { customerId: customer.id, productId } });
} else { } else {
// productId kommt unverifiziert aus dem Formular. Ohne diese Pruefung lief
// ein erfundener Wert in eine Fremdschluesselverletzung und damit in die
// allgemeine Fehlerseite — ein 500er als Antwort auf eine Eingabe, die
// schlicht ungueltig ist. Ein aus dem Verkauf genommenes Produkt gehoert
// ausserdem nicht neu auf eine Wunschliste; bereits gemerkte bleiben
// unberuehrt, damit sie sich weiterhin entfernen lassen.
const product = await prisma.product.findUnique({
where: { id: productId },
select: { isActive: true },
});
if (!product?.isActive) {
redirect(redirectPath);
}
await prisma.wishlistItem.create({ data: { customerId: customer.id, productId } }); await prisma.wishlistItem.create({ data: { customerId: customer.id, productId } });
} }
+9 -1
View File
@@ -28,7 +28,15 @@ export default async function ImpressumPage() {
}, },
{ {
heading: t.impressumPage.contactHeading, heading: t.impressumPage.contactHeading,
paragraphs: [`${t.impressumPage.phoneLabel}: ${company.phone}`, `${t.impressumPage.emailLabel}: ${company.email}`], // Die Telefonzeile nur, wenn eine Nummer gepflegt ist. § 5 TMG
// verlangt eine Moeglichkeit zur unmittelbaren Kontaktaufnahme,
// nicht zwingend ein Telefon — die E-Mail-Adresse erfuellt das.
// Eine Platzhalternummer im Impressum waere dagegen eine Angabe,
// die ins Leere fuehrt.
paragraphs: [
...(company.phone ? [`${t.impressumPage.phoneLabel}: ${company.phone}`] : []),
`${t.impressumPage.emailLabel}: ${company.email}`,
],
}, },
ustSection, ustSection,
redaktionSection, redaktionSection,
+4 -1
View File
@@ -35,7 +35,10 @@ export default async function KontaktPage() {
{company.email} {company.email}
</a> </a>
</p> </p>
<p>{company.phone}</p> {/* Ohne gepflegte Nummer entfaellt die Zeile ganz. Eine
Platzhalternummer waere schlimmer als keine: sie sieht nach
einem Kontaktweg aus, an dessen Ende niemand abnimmt. */}
{company.phone ? <p>{company.phone}</p> : null}
</ScrollReveal> </ScrollReveal>
<ScrollReveal delay={0.18} className="mt-12 border-t border-ink/10 pt-8"> <ScrollReveal delay={0.18} className="mt-12 border-t border-ink/10 pt-8">
+126
View File
@@ -0,0 +1,126 @@
/**
* Regressionstests fuer das Turnstile-Widget.
*
* Der behobene Fehler war unauffaellig und folgenschwer zugleich: das Widget
* erschien nicht, es gab also kein Token, und ohne Token weist der Server das
* Formular ab — Anmeldung, Registrierung und Checkout waren blockiert, ohne
* dass irgendwo eine Fehlermeldung stand. Ausgeloest wurde er dadurch, dass
* next/script sein onLoad fuer eine bereits geladene Quelle nicht erneut
* ausfuehrt: das ZWEITE Widget einer Seite und jedes Widget nach einer
* Navigation im Seitenverlauf warteten auf ein Ereignis, das nie kam.
*
* Diese Tests halten genau das fest: ein Widget muss sich auch dann aufbauen,
* wenn das Skript laengst geladen ist.
*/
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TurnstileWidget } from "./turnstile-widget";
import { I18nProvider } from "@/i18n/i18n-provider";
const SITE_KEY = "1x00000000000000000000AA";
// Mit Signatur, damit mock.calls[0][1] (die Optionen) typisiert ist.
const turnstileRender =
vi.fn<(container: HTMLElement, options: Record<string, unknown>) => string>(() => "widget-1");
const turnstileRemove = vi.fn();
function mountWidget(onVerify: (token: string) => void = vi.fn()) {
return render(
<I18nProvider locale="de">
<TurnstileWidget siteKey={SITE_KEY} onVerify={onVerify} />
</I18nProvider>
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
delete window.turnstile;
document.querySelectorAll("script").forEach((script) => script.remove());
});
describe("TurnstileWidget", () => {
test("baut sich auch dann auf, wenn das Skript schon geladen ist", async () => {
// Genau der Fall, der vorher stillschweigend nichts tat: window.turnstile
// existiert bereits (weil die Newsletter-Anmeldung im Footer das Skript
// auf praktisch jeder Seite geladen hat), ein onLoad kommt also nie mehr.
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
mountWidget();
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
expect(turnstileRender.mock.calls[0][1]).toMatchObject({ sitekey: SITE_KEY });
});
test("baut zwei Widgets auf derselben Seite unabhaengig voneinander auf", async () => {
// Die Kontoseite zeigt Anmeldung und Registrierung untereinander, jede mit
// eigenem Widget. Vorher bekam nur eine von beiden eines.
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
render(
<I18nProvider locale="de">
<TurnstileWidget siteKey={SITE_KEY} onVerify={vi.fn()} />
<TurnstileWidget siteKey={SITE_KEY} onVerify={vi.fn()} />
</I18nProvider>
);
await waitFor(() => expect(turnstileRender).toHaveBeenCalledTimes(2));
});
test("reicht das geloeste Token an das Formular weiter", async () => {
const onVerify = vi.fn();
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
mountWidget(onVerify);
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
const options = turnstileRender.mock.calls[0][1] as Record<string, (token: string) => void>;
options.callback("0.abc123");
expect(onVerify).toHaveBeenCalledWith("0.abc123");
// Ein abgelaufenes Token muss das Formular ebenso erfahren, sonst schickt
// es ein laengst ungueltiges mit.
options["expired-callback"]("");
expect(onVerify).toHaveBeenLastCalledWith("");
});
test("raeumt das Widget beim Verlassen der Seite wieder ab", async () => {
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
const { unmount } = mountWidget();
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
unmount();
expect(turnstileRemove).toHaveBeenCalledWith("widget-1");
});
test("zeigt einen Hinweis mit Wiederholung, wenn das Skript nicht laedt", async () => {
// jsdom laedt keine externen Skripte — der Fehlerfall wird ausgeloest,
// indem auf dem eingehaengten Element ein "error" ausgeloest wird.
const appendChild = vi
.spyOn(document.head, "appendChild")
.mockImplementation((<T extends Node>(node: T): T => {
queueMicrotask(() => node.dispatchEvent(new Event("error")));
return node;
}) as typeof document.head.appendChild);
mountWidget();
// Der Hinweis ist der eigentliche Fortschritt: vorher blieb an dieser
// Stelle schlicht eine leere Flaeche stehen.
const retry = await screen.findByRole("button", { name: /erneut versuchen/i });
expect(screen.getByText(/sicherheitsprüfung konnte nicht geladen werden/i)).toBeInTheDocument();
// Wiederholung mit inzwischen verfuegbarem Skript: das Widget kommt.
appendChild.mockRestore();
window.turnstile = { render: turnstileRender, remove: turnstileRemove };
await userEvent.click(retry);
await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce());
});
});
+151 -18
View File
@@ -1,16 +1,97 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Script from "next/script"; import { useI18n } from "@/i18n/i18n-provider";
interface TurnstileApi {
render: (container: HTMLElement, options: Record<string, unknown>) => string;
remove: (widgetId: string) => void;
}
declare global { declare global {
interface Window { interface Window {
turnstile?: { turnstile?: TurnstileApi;
render: (container: HTMLElement, options: Record<string, unknown>) => string;
};
} }
} }
// "render=explicit" schaltet die automatische Suche nach .cf-turnstile-
// Elementen ab: dieses Widget wird ausschliesslich von Hand gerendert, damit
// wir die Widget-ID kennen und es beim Verlassen wieder entfernen koennen.
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
/**
* Laedt das Turnstile-Skript je Seitenbesuch genau einmal; alle Widgets
* teilen sich dieses eine Versprechen.
*
* Zuvor uebernahm das <Script> von next/script diese Aufgabe, und genau daran
* scheiterte es: next/script fuehrt fuer eine bereits geladene Quelle KEIN
* onLoad mehr aus. Der Schluessel ist dabei die URL (id || src), also fuer
* alle Widgets derselbe — nachzulesen in next/dist/client/script.js:
*
* const cacheKey = id || src;
* // Script has already loaded
* if (cacheKey && LoadCache.has(cacheKey)) {
* return;
* }
*
* Damit bekam nur das ERSTE Widget nach einem echten Seitenaufbau sein
* onLoad. Jedes weitere wartete auf ein Ereignis, das nie kam, und blieb
* unsichtbar: das zweite Widget auf der Kontoseite (Anmeldung und
* Registrierung stehen dort untereinander) ebenso wie jedes Formular, das
* ueber einen Link im Seitenverlauf erreicht wurde statt ueber F5. Weil die
* Newsletter-Anmeldung im Footer steht, ist das Skript praktisch immer schon
* geladen — fast jeder Weg durch die Seite fuehrte also in den kaputten
* Zustand, und nur ein Neuladen half.
*
* Deshalb haengt hier nichts mehr an einem Ereignis, das man verpassen kann:
* es zaehlt allein, ob window.turnstile vorhanden ist.
*/
let loadPromise: Promise<TurnstileApi> | null = null;
function loadTurnstile(): Promise<TurnstileApi> {
if (window.turnstile) return Promise.resolve(window.turnstile);
if (loadPromise) return loadPromise;
const pending = new Promise<TurnstileApi>((resolve, reject) => {
// Ein vorhandenes Skript-Element wiederverwenden, statt ein zweites
// einzuhaengen: nach einem fehlgeschlagenen Versuch (loadPromise wird
// unten zurueckgesetzt) steht es unter Umstaenden noch im Dokument.
const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC}"]`);
const script = existing ?? document.createElement("script");
script.addEventListener(
"load",
() => {
if (window.turnstile) resolve(window.turnstile);
else reject(new Error("Turnstile geladen, aber window.turnstile fehlt."));
},
{ once: true }
);
script.addEventListener(
"error",
() => reject(new Error("Turnstile-Skript konnte nicht geladen werden.")),
{ once: true }
);
if (!existing) {
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
});
// Nach einem Fehlschlag darf der naechste Versuch nicht auf demselben
// abgelehnten Versprechen haengen bleiben — sonst waere der "Erneut
// versuchen"-Knopf wirkungslos.
loadPromise = pending;
pending.catch(() => {
loadPromise = null;
});
return pending;
}
interface TurnstileWidgetProps { interface TurnstileWidgetProps {
siteKey: string; siteKey: string;
onVerify: (token: string) => void; onVerify: (token: string) => void;
@@ -21,30 +102,82 @@ interface TurnstileWidgetProps {
* NEXT_PUBLIC_TURNSTILE_SITE_KEY konfiguriert ist (siehe README) — ohne * NEXT_PUBLIC_TURNSTILE_SITE_KEY konfiguriert ist (siehe README) — ohne
* Konfiguration rendern die umgebenden Formulare gar nicht erst diese * Konfiguration rendern die umgebenden Formulare gar nicht erst diese
* Komponente und bleiben unveraendert nutzbar. * Komponente und bleiben unveraendert nutzbar.
*
* Achtung beim Anpassen: ohne sichtbares Widget gibt es kein Token, und ohne
* Token weist der Server das Formular ab. Ein stiller Ausfall hier sperrt
* Kundschaft aus Anmeldung, Registrierung und Checkout aus, ohne dass
* irgendwo etwas davon zu sehen waere — daher der sichtbare Hinweis samt
* Wiederholung unten.
*/ */
export function TurnstileWidget({ siteKey, onVerify }: TurnstileWidgetProps) { export function TurnstileWidget({ siteKey, onVerify }: TurnstileWidgetProps) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [scriptLoaded, setScriptLoaded] = useState(false); const widgetIdRef = useRef<string | null>(null);
const onVerifyRef = useRef(onVerify);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
// Den Callback im Ref halten, statt ihn in die Abhaengigkeiten zu nehmen:
// eine bei jedem Render neu erzeugte Funktion wuerde das Widget sonst
// staendig abbauen und neu aufbauen — und mit ihm ein bereits geloestes
// CAPTCHA.
useEffect(() => {
onVerifyRef.current = onVerify;
}, [onVerify]);
useEffect(() => { useEffect(() => {
if (!scriptLoaded || !containerRef.current || !window.turnstile) return; let cancelled = false;
window.turnstile.render(containerRef.current, {
// setFailed gehoert in die Rueckrufe, nicht in den Rumpf des Effekts: ein
// synchrones setState loest eine sofortige zweite Renderrunde aus (siehe
// react-hooks/set-state-in-effect). Beim Wiederholen wird der Hinweis
// deshalb erst zurueckgenommen, wenn das Laden tatsaechlich geklappt hat.
loadTurnstile()
.then((turnstile) => {
if (cancelled || !containerRef.current) return;
setFailed(false);
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: siteKey, sitekey: siteKey,
callback: onVerify, callback: (token: string) => onVerifyRef.current(token),
"expired-callback": () => onVerify(""), // Ein abgelaufenes oder fehlgeschlagenes Token muss das Formular
"error-callback": () => onVerify(""), // erfahren, sonst schickt es ein laengst ungueltiges mit.
"expired-callback": () => onVerifyRef.current(""),
"error-callback": () => onVerifyRef.current(""),
}); });
}, [scriptLoaded, siteKey, onVerify]); })
.catch((error: unknown) => {
if (cancelled) return;
console.error("[turnstile] Widget konnte nicht geladen werden:", error);
setFailed(true);
});
return () => {
cancelled = true;
const widgetId = widgetIdRef.current;
widgetIdRef.current = null;
if (!widgetId) return;
try {
window.turnstile?.remove(widgetId);
} catch (error: unknown) {
// Nicht weiterwerfen: ein Fehler beim Aufraeumen darf den
// Seitenwechsel nicht abbrechen.
console.warn("[turnstile] Widget konnte nicht entfernt werden:", error);
}
};
}, [siteKey, attempt]);
return ( return (
<> <div>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
onLoad={() => setScriptLoaded(true)}
/>
<div ref={containerRef} /> <div ref={containerRef} />
</> {failed ? (
<p className="text-sm text-red-700">
{t.common.turnstile.loadError}{" "}
<button type="button" onClick={() => setAttempt((value) => value + 1)} className="underline">
{t.common.turnstile.retry}
</button>
</p>
) : null}
</div>
); );
} }
+8 -1
View File
@@ -9,6 +9,10 @@ export const de: Dictionary = {
KERZEN: "Bienenwachskerzen", KERZEN: "Bienenwachskerzen",
PROPOLIS: "Propolis", PROPOLIS: "Propolis",
}, },
turnstile: {
loadError: "Die Sicherheitsprüfung konnte nicht geladen werden.",
retry: "Erneut versuchen",
},
}, },
nav: { nav: {
shop: "Shop", shop: "Shop",
@@ -56,7 +60,9 @@ export const de: Dictionary = {
viewCart: "Warenkorb ansehen", viewCart: "Warenkorb ansehen",
}, },
orderStatusBadge: { orderStatusBadge: {
NEU: "Neu", // Nicht "Neu": dieser Status bedeutet, dass die Zahlung noch nicht
// bestaetigt ist — bei einem abgebrochenen Checkout bleibt sie es auch.
NEU: "Zahlung ausstehend",
BEZAHLT: "Bezahlt", BEZAHLT: "Bezahlt",
IN_VORBEREITUNG: "In Vorbereitung", IN_VORBEREITUNG: "In Vorbereitung",
VERSANDBEREIT: "Versandbereit", VERSANDBEREIT: "Versandbereit",
@@ -1340,6 +1346,7 @@ export const de: Dictionary = {
eyebrow: "Abgebrochen", eyebrow: "Abgebrochen",
title: "Deine Bestellung wurde nicht abgeschlossen.", title: "Deine Bestellung wurde nicht abgeschlossen.",
text: "Kein Problem, dein Warenkorb ist noch gefüllt. Du kannst den Bestellvorgang jederzeit erneut starten.", text: "Kein Problem, dein Warenkorb ist noch gefüllt. Du kannst den Bestellvorgang jederzeit erneut starten.",
released: "Die für dich zurückgelegte Ware steht wieder im Shop zur Verfügung.",
backToCart: "Zurück zum Warenkorb", backToCart: "Zurück zum Warenkorb",
}, },
checkoutPage: { checkoutPage: {
+8 -1
View File
@@ -9,6 +9,10 @@ export const en: Dictionary = {
KERZEN: "Beeswax candles", KERZEN: "Beeswax candles",
PROPOLIS: "Propolis", PROPOLIS: "Propolis",
}, },
turnstile: {
loadError: "The security check could not be loaded.",
retry: "Try again",
},
}, },
nav: { nav: {
shop: "Shop", shop: "Shop",
@@ -56,7 +60,9 @@ export const en: Dictionary = {
viewCart: "View cart", viewCart: "View cart",
}, },
orderStatusBadge: { orderStatusBadge: {
NEU: "New", // Not "New": this status means the payment has not been confirmed yet —
// after an abandoned checkout it never will be.
NEU: "Awaiting payment",
BEZAHLT: "Paid", BEZAHLT: "Paid",
IN_VORBEREITUNG: "Preparing", IN_VORBEREITUNG: "Preparing",
VERSANDBEREIT: "Ready to ship", VERSANDBEREIT: "Ready to ship",
@@ -1339,6 +1345,7 @@ export const en: Dictionary = {
eyebrow: "Cancelled", eyebrow: "Cancelled",
title: "Your order was not completed.", title: "Your order was not completed.",
text: "No problem, your cart is still full. You can restart the checkout process at any time.", text: "No problem, your cart is still full. You can restart the checkout process at any time.",
released: "The items reserved for you are available in the shop again.",
backToCart: "Back to cart", backToCart: "Back to cart",
}, },
checkoutPage: { checkoutPage: {
+6
View File
@@ -46,6 +46,11 @@ export interface Dictionary {
PROPOLIS: string; PROPOLIS: string;
}; };
siteDescription: string; siteDescription: string;
/** Sicherheitspruefung (Cloudflare Turnstile) konnte nicht geladen werden. */
turnstile: {
loadError: string;
retry: string;
};
}; };
nav: { nav: {
shop: string; shop: string;
@@ -1021,6 +1026,7 @@ export interface Dictionary {
eyebrow: string; eyebrow: string;
title: string; title: string;
text: string; text: string;
released: string;
backToCart: string; backToCart: string;
}; };
checkoutPage: { checkoutPage: {
+22 -1
View File
@@ -10,9 +10,30 @@ export type OrderWithDetails = Order & {
returnRequest: ReturnRequest | null; returnRequest: ReturnRequest | null;
}; };
/**
* Bestellungen eines Kontos — OHNE die noch unbezahlten.
*
* Eine Bestellung entsteht bereits beim Start des Checkouts und steht bis zur
* bestaetigten Zahlung auf "NEU". Die allermeisten dieser Datensaetze sind
* keine Bestellungen, sondern abgebrochene Kaufversuche: geschlossener Tab,
* Zurueck-Knopf, weggelegtes Handy. Sie verschwinden von selbst wieder (siehe
* lib/order-release.ts), aber bis dahin vergehen je nach Weg bis zu 45
* Minuten.
*
* In dieser Zeit standen sie hier wie vollwertige Bestellungen in der Liste —
* mit Bestellnummer, Summe und Statuskennzeichen. Wer den Kauf gerade
* abgebrochen hatte, fand ihn also im Konto trotzdem als Bestellung wieder
* und musste annehmen, dass doch etwas zustande gekommen ist.
*
* Bezahlte Bestellungen sind davon nicht betroffen: der Webhook setzt sie
* binnen Sekunden auf "BEZAHLT", danach erscheinen sie hier wie gewohnt.
* "STORNIERT" bleibt bewusst sichtbar — dort gab es einen Vorgang, den die
* Kundschaft nachvollziehen koennen muss (fehlgeschlagener Einzug, Storno
* durch die Imkerei).
*/
export async function getOrdersForCustomer(customerId: string): Promise<OrderWithDetails[]> { export async function getOrdersForCustomer(customerId: string): Promise<OrderWithDetails[]> {
return prisma.order.findMany({ return prisma.order.findMany({
where: { customerId }, where: { customerId, status: { not: "NEU" } },
include: { include: {
items: { include: { product: { select: { slug: true } } } }, items: { include: { product: { select: { slug: true } } } },
shipment: true, shipment: true,
+16 -2
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { getContentValues } from "@/lib/content"; import { getContentValues } from "@/lib/content";
@@ -20,8 +21,21 @@ const COMPANY_KEYS = [
* Konsumenten nur `siteConfig` gegen `await getCompanyInfo()` tauschen * Konsumenten nur `siteConfig` gegen `await getCompanyInfo()` tauschen
* muessen. domain/country bleiben aus siteConfig (keine Content-Felder, * muessen. domain/country bleiben aus siteConfig (keine Content-Felder,
* echte Infra-Werte). * echte Infra-Werte).
*
* In React-cache() gehuellt, weil diese Funktion je Seitenaufruf MEHRFACH
* aufgerufen wird: allein das Root-Layout braucht sie zweimal (einmal fuer
* generateMetadata, einmal fuer das LocalBusiness-Schema im Rumpf), dazu
* kommt fast jede Seite mit einem eigenen Aufruf. Ohne die Huelle war das je
* Aufruf eine eigene Datenbankabfrage ueber dieselben zehn Schluessel —
* dreimal dasselbe Ergebnis pro gerenderter Seite.
*
* cache() ist auf EINEN Request begrenzt und kein serverweiter Zwischen-
* speicher: eine Aenderung im Admin-Bereich wirkt unveraendert sofort, es
* gibt nichts zu invalidieren. Die Funktion nimmt keine Argumente, damit
* greift die Deduplizierung zuverlaessig (cache() unterscheidet Aufrufe
* anhand der Argumente).
*/ */
export async function getCompanyInfo() { export const getCompanyInfo = cache(async () => {
// Firmendaten sind nicht lokalisiert (siehe content-registry.ts) — die // Firmendaten sind nicht lokalisiert (siehe content-registry.ts) — die
// uebergebene Locale wirkt sich fuer diese Schluessel nicht aus. // uebergebene Locale wirkt sich fuer diese Schluessel nicht aus.
const v = await getContentValues([...COMPANY_KEYS], "de"); const v = await getContentValues([...COMPANY_KEYS], "de");
@@ -41,4 +55,4 @@ export async function getCompanyInfo() {
city: v["company.city"], city: v["company.city"],
}, },
}; };
} });
+34
View File
@@ -0,0 +1,34 @@
import { describe, test, expect } from "vitest";
import { formatInvoiceNumber } from "./invoice-number";
describe("formatInvoiceNumber", () => {
test("beginnt jedes Jahr bei 0001", () => {
// Arrange & Act & Assert
expect(formatInvoiceNumber(2026, 1)).toBe("RE-2026-0001");
expect(formatInvoiceNumber(2027, 1)).toBe("RE-2027-0001");
});
test("zaehlt fortlaufend weiter", () => {
// Arrange & Act & Assert: genau darum geht es — § 14 Abs. 4 Nr. 4 UStG
// verlangt eine fortlaufende, nicht bloss eine eindeutige Nummer.
expect(formatInvoiceNumber(2026, 2)).toBe("RE-2026-0002");
expect(formatInvoiceNumber(2026, 42)).toBe("RE-2026-0042");
});
test("haelt die Nummern gleich lang, damit sie sortierbar bleiben", () => {
// Arrange
const numbers = [1, 9, 10, 999].map((n) => formatInvoiceNumber(2026, n));
// Act & Assert: alphabetisch sortiert ergibt sich dieselbe Reihenfolge wie
// numerisch — bei ungepolsterten Nummern kaeme "RE-2026-10" vor
// "RE-2026-9".
expect([...numbers].sort()).toEqual(numbers);
});
test("waechst ueber vier Stellen hinaus, statt abzuschneiden", () => {
// Arrange & Act & Assert: eine abgeschnittene Nummer waere nicht mehr
// eindeutig — lieber eine Stelle mehr als eine doppelte Rechnungsnummer.
expect(formatInvoiceNumber(2026, 9999)).toBe("RE-2026-9999");
expect(formatInvoiceNumber(2026, 10000)).toBe("RE-2026-10000");
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Aufbau der Rechnungsnummer.
*
* § 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Nummer, die der
* Rechnungsaussteller einmalig vergibt. Vorher stand hier eine Zufallszahl:
* eindeutig ja, fortlaufend nein — bei einer Betriebspruefung liesse sich
* damit nicht zeigen, dass keine Rechnung fehlt.
*
* Die laufende Nummer selbst kommt aus der Tabelle invoice_counters, deren
* Zeilensperre beim Hochzaehlen sicherstellt, dass keine Nummer zweimal
* vergeben wird (siehe prisma/schema.prisma).
*/
/**
* Mindestbreite der laufenden Nummer. Vier Stellen halten die Nummern gleich
* lang und damit sortierbar; gibt es in einem Jahr mehr als 9999 Rechnungen,
* waechst die Nummer einfach mit, statt abgeschnitten zu werden.
*/
const SEQUENCE_DIGITS = 4;
/** Bildet "RE-2026-0001" aus Jahr und laufender Nummer. */
export function formatInvoiceNumber(year: number, sequence: number): string {
return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`;
}
+5 -5
View File
@@ -7,7 +7,7 @@ function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]>
year: 2026, year: 2026,
lastName: "Mustermann", lastName: "Mustermann",
firstName: "Erika", firstName: "Erika",
invoiceNumber: "RE-2026-A3F9C1B2", invoiceNumber: "RE-2026-0001",
...overrides, ...overrides,
}; };
} }
@@ -18,14 +18,14 @@ describe("invoiceRelativePath", () => {
const relative = invoiceRelativePath(makeInput()); const relative = invoiceRelativePath(makeInput());
// Assert // Assert
expect(relative).toBe("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf"); expect(relative).toBe("2026/mustermann-erika/RE-2026-0001.pdf");
}); });
test("schreibt Umlaute im Kundenordner aus", () => { test("schreibt Umlaute im Kundenordner aus", () => {
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus // Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
// "Mueller" ein "m-ller" und damit unleserliche Ordner. // "Mueller" ein "m-ller" und damit unleserliche Ordner.
expect(invoiceRelativePath(makeInput({ lastName: "Müller", firstName: "Jörg" }))).toBe( expect(invoiceRelativePath(makeInput({ lastName: "Müller", firstName: "Jörg" }))).toBe(
"2026/mueller-joerg/RE-2026-A3F9C1B2.pdf" "2026/mueller-joerg/RE-2026-0001.pdf"
); );
}); });
@@ -54,7 +54,7 @@ describe("invoiceRelativePath", () => {
test("faellt auf 'unbekannt' zurueck, statt ein leeres Pfadsegment zu erzeugen", () => { test("faellt auf 'unbekannt' zurueck, statt ein leeres Pfadsegment zu erzeugen", () => {
// Arrange & Act & Assert: "2026//RE-....pdf" waere ein kaputter Pfad. // Arrange & Act & Assert: "2026//RE-....pdf" waere ein kaputter Pfad.
expect(invoiceRelativePath(makeInput({ lastName: "***", firstName: "###" }))).toBe( expect(invoiceRelativePath(makeInput({ lastName: "***", firstName: "###" }))).toBe(
"2026/unbekannt/RE-2026-A3F9C1B2.pdf" "2026/unbekannt/RE-2026-0001.pdf"
); );
}); });
}); });
@@ -75,7 +75,7 @@ describe("Trennung von der oeffentlichen Upload-Ablage", () => {
describe("resolveInvoicePath", () => { describe("resolveInvoicePath", () => {
test("loest innerhalb des Ablageverzeichnisses auf", () => { test("loest innerhalb des Ablageverzeichnisses auf", () => {
// Arrange & Act // Arrange & Act
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf"); const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-0001.pdf");
// Assert // Assert
expect(resolved.startsWith(INVOICE_DIR)).toBe(true); expect(resolved.startsWith(INVOICE_DIR)).toBe(true);
+1 -1
View File
@@ -81,7 +81,7 @@ export interface InvoicePathInput {
/** /**
* Pfad einer Rechnung RELATIV zu INVOICE_DIR, nach Jahr und Kunde sortiert: * Pfad einer Rechnung RELATIV zu INVOICE_DIR, nach Jahr und Kunde sortiert:
* *
* 2026/mustermann-erika/RE-2026-A3F9C1B2.pdf * 2026/mustermann-erika/RE-2026-0001.pdf
* *
* Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein * Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein
* abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. * abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren.
+105
View File
@@ -0,0 +1,105 @@
import { prisma } from "@/lib/prisma";
import { restockOrderItems } from "@/lib/inventory";
export interface ReleasedOrder {
orderNumber: string;
email: string;
firstName: string;
locale: string;
}
/**
* Was mit einer unbezahlten Bestellung geschehen soll.
*
* "delete" — die Bestellung wird spurlos entfernt. Richtig fuer alles, was nie
* bezahlt und nie bestaetigt wurde: den Abbruch bei Stripe und die abgelaufene
* Sitzung. Eine stornierte Bestellung stehen zu lassen stiftet nur Verwirrung
* — im Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
* gescheiterter Verkauf. Aufbewahrungspflichten entstehen dadurch nicht: es
* gab kein Geschaeft, keine Zahlung und keine Rechnung.
*
* "cancel" — die Bestellung bleibt als "STORNIERT" bestehen. Richtig, wenn
* tatsaechlich eine Zahlung versucht wurde und fehlschlug (SEPA-Lastschrift,
* teils Klarna: die Kundschaft hat den Checkout abgeschlossen und rechnet mit
* einer Lieferung). Hier gibt es etwas nachzuvollziehen, und die Kundschaft
* bekommt eine Nachricht dazu.
*/
export type ReleaseMode = "delete" | "cancel";
/**
* Gibt den beim Checkout reservierten Lagerbestand wieder frei.
*
* Eine Bestellung entsteht BEVOR die Stripe-Sitzung geoeffnet wird, und sie
* reserviert dabei sofort Bestand — sonst koennte zwischen Kaufentscheidung
* und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafuer: jeder
* nicht zu Ende gefuehrte Checkout haelt Ware fest, die niemand bezahlt hat.
* Diese Funktion ist die Gegenbuchung dazu und wird von allen Wegen benutzt,
* auf denen das auffallen kann: dem Abbruch durch die Kundschaft, dem Ablauf
* der Stripe-Sitzung (Webhook), dem fehlgeschlagenen Einzug und dem
* Aufraeumlauf (scripts/release-stale-orders.ts).
*
* Der Zugriff ist in beiden Betriebsarten ein einzelnes bedingtes Schreiben
* ("nur wenn noch NEU"). Ein getrenntes Lesen und Schreiben wuerde bei zwei
* gleichzeitigen Ausloesern — etwa Abbruch-Klick und Webhook im selben Moment
* — beide Seiten noch "NEU" lesen lassen, und der Bestand wuerde doppelt
* gutgeschrieben. Genau deshalb gibt diese Funktion beim zweiten Aufruf null
* zurueck: nicht als Fehler, sondern als Aussage "hat schon jemand anders
* erledigt".
*
* @returns die freigegebene Bestellung, oder null wenn sie nicht (mehr) im
* Status "NEU" war und deshalb nichts zu tun ist.
*/
export async function releaseUnpaidOrder(
orderId: string,
mode: ReleaseMode,
paidAmountCents = 0
): Promise<ReleasedOrder | null> {
return prisma.$transaction(async (tx) => {
// Vor dem Schreiben lesen, nicht danach: beim Loeschen verschwinden die
// Positionen mit der Bestellung (onDelete: Cascade), und ohne sie liesse
// sich der Bestand nicht mehr zurueckbuchen.
const previous = await tx.order.findUnique({
where: { id: orderId },
select: {
status: true,
orderNumber: true,
email: true,
firstName: true,
locale: true,
items: { select: { productId: true, quantity: true } },
},
});
if (!previous || previous.status !== "NEU") return null;
if (mode === "delete") {
// deleteMany mit der Statusbedingung ist hier der atomare Zugriff: es
// loescht entweder genau diese eine noch unbezahlte Bestellung oder
// nichts. Hat inzwischen eine eingehende Zahlung den Status auf
// "BEZAHLT" gesetzt, faengt count === 0 das ab — eine bezahlte
// Bestellung darf dieser Weg unter keinen Umstaenden entfernen.
const deleted = await tx.order.deleteMany({ where: { id: orderId, status: "NEU" } });
if (deleted.count === 0) return null;
} else {
const claim = await tx.order.updateMany({
where: { id: orderId, status: "NEU" },
data: { status: "STORNIERT" },
});
if (claim.count === 0) return null;
await tx.payment.upsert({
where: { orderId },
create: { orderId, status: "FAILED", amountCents: paidAmountCents },
update: { status: "FAILED" },
});
}
await restockOrderItems(tx, previous.items);
return {
orderNumber: previous.orderNumber,
email: previous.email,
firstName: previous.firstName,
locale: previous.locale,
};
});
}
+164
View File
@@ -0,0 +1,164 @@
// @vitest-environment node
//
// Abweichend von der globalen jsdom-Umgebung (vitest.config.ts): "jose" prueft
// seine Eingabe mit "instanceof Uint8Array", und das von jsdom bereitgestellte
// TextEncoder liefert ein Uint8Array aus einem anderen JS-Realm — die Pruefung
// schlaegt dann fehl, obwohl der Code stimmt. Im Betrieb laeuft dieses Modul
// ohnehin nie in einem DOM.
/**
* Tests fuer die Token-Schicht der Anmeldung.
*
* Alle Token in session.ts sind mit DEMSELBEN Geheimnis signiert und tragen
* denselben Aussteller und dieselbe Zielgruppe. Was sie unterscheidet, ist
* allein das "type"-Feld. Damit ist die zentrale Frage nicht, ob ein gueltiger
* Token angenommen wird, sondern ob ein Token FUER EINEN ANDEREN ZWECK
* abgelehnt wird — sonst laesst sich der zweite Faktor umgehen, indem man das
* Challenge-Cookie in das Session-Cookie kopiert.
*/
import { describe, test, expect } from "vitest";
// Vor dem Import setzen: getSecretKey wirft ohne diesen Wert.
process.env.SESSION_SECRET = "test-secret-mit-mindestens-32-zeichen-laenge";
const {
signSessionToken,
verifySessionToken,
signTwoFactorChallenge,
verifyTwoFactorChallenge,
signTwoFactorSetup,
verifyTwoFactorSetup,
signTwoFactorBackupCodesDisplay,
verifyTwoFactorBackupCodesDisplay,
signEntraOAuthState,
verifyEntraOAuthState,
} = await import("./session");
const CUSTOMER_ID = "customer_123";
describe("Session-Token", () => {
test("gibt Kunde, Rolle und Sitzungsversion unveraendert zurueck", async () => {
const token = await signSessionToken({
customerId: CUSTOMER_ID,
role: "ADMIN",
sessionVersion: 3,
});
await expect(verifySessionToken(token)).resolves.toEqual({
customerId: CUSTOMER_ID,
role: "ADMIN",
sessionVersion: 3,
});
});
test("stuft eine unbekannte Rolle auf KUNDE herunter statt sie zu uebernehmen", async () => {
// Die Rolle im Token ist nur der schnelle Vorfilter fuer den Proxy;
// autoritativ ist die Datenbank. Ein unerwarteter Wert darf trotzdem nie
// als etwas anderes als die geringste Berechtigung durchgehen.
const token = await signSessionToken({
customerId: CUSTOMER_ID,
role: "SUPERADMIN" as never,
sessionVersion: 0,
});
await expect(verifySessionToken(token)).resolves.toMatchObject({ role: "KUNDE" });
});
test("lehnt einen manipulierten Token ab", async () => {
const token = await signSessionToken({
customerId: CUSTOMER_ID,
role: "KUNDE",
sessionVersion: 0,
});
// Letztes Zeichen der Signatur veraendern.
const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
await expect(verifySessionToken(tampered)).resolves.toBeNull();
});
test("lehnt Unsinn ab, statt zu werfen", async () => {
await expect(verifySessionToken("kein.jwt.hier")).resolves.toBeNull();
await expect(verifySessionToken("")).resolves.toBeNull();
});
});
describe("Token-Verwechslung", () => {
/**
* Der wichtigste Test dieser Datei.
*
* Das Challenge-Cookie wird ausgestellt, NACHDEM das Passwort stimmt, aber
* BEVOR der zweite Faktor geprueft ist. Gilt es als Session, ist die
* Zwei-Faktor-Pruefung eine Formalie, die sich mit einem Kopiervorgang im
* Browser ueberspringen laesst.
*/
test("ein 2FA-Challenge-Token ist KEINE gueltige Anmeldung", async () => {
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
await expect(verifySessionToken(challenge)).resolves.toBeNull();
});
test("ein 2FA-Einrichtungstoken ist KEINE gueltige Anmeldung", async () => {
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
await expect(verifySessionToken(setup)).resolves.toBeNull();
});
test("ein Backup-Code-Anzeigetoken ist KEINE gueltige Anmeldung", async () => {
const display = await signTwoFactorBackupCodesDisplay(CUSTOMER_ID, ["aaaa-bbbb"]);
await expect(verifySessionToken(display)).resolves.toBeNull();
});
test("ein Entra-OAuth-State ist KEINE gueltige Anmeldung", async () => {
const state = await signEntraOAuthState("zufallswert", "code-verifier");
await expect(verifySessionToken(state)).resolves.toBeNull();
});
test("ein Session-Token taugt umgekehrt fuer keinen der Sonderzwecke", async () => {
const session = await signSessionToken({
customerId: CUSTOMER_ID,
role: "ADMIN",
sessionVersion: 0,
});
await expect(verifyTwoFactorChallenge(session)).resolves.toBeNull();
await expect(verifyTwoFactorSetup(session)).resolves.toBeNull();
await expect(verifyTwoFactorBackupCodesDisplay(session)).resolves.toBeNull();
await expect(verifyEntraOAuthState(session)).resolves.toBeNull();
});
test("die Sondertoken lassen sich auch untereinander nicht tauschen", async () => {
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
// Ein Einrichtungstoken darf keinen Anmeldeschritt abschliessen, und ein
// Challenge-Token traegt kein Secret, mit dem sich 2FA einrichten liesse.
await expect(verifyTwoFactorChallenge(setup)).resolves.toBeNull();
await expect(verifyTwoFactorSetup(challenge)).resolves.toBeNull();
});
});
describe("Sondertoken im Normalfall", () => {
test("Challenge und Einrichtung geben ihren Inhalt zurueck", async () => {
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
await expect(verifyTwoFactorChallenge(challenge)).resolves.toEqual({
customerId: CUSTOMER_ID,
});
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
await expect(verifyTwoFactorSetup(setup)).resolves.toEqual({
customerId: CUSTOMER_ID,
secret: "JBSWY3DPEHPK3PXP",
});
});
test("der Entra-State haelt den PKCE-Verifier, der nie in die URL gehoert", async () => {
const state = await signEntraOAuthState("zufallswert", "code-verifier");
await expect(verifyEntraOAuthState(state)).resolves.toEqual({
state: "zufallswert",
codeVerifier: "code-verifier",
});
});
});
+23
View File
@@ -62,6 +62,29 @@ export async function verifySessionToken(token: string): Promise<SessionPayload
algorithms: ["HS256"], algorithms: ["HS256"],
}); });
if (!payload.sub) return null; if (!payload.sub) return null;
// Alle Sondertoken in dieser Datei — 2FA-Challenge, 2FA-Einrichtung,
// Backup-Code-Anzeige, Entra-OAuth-State — sind mit DEMSELBEN Geheimnis
// signiert und tragen denselben Aussteller und dieselbe Zielgruppe. Ihr
// einziger Unterschied ist das "type"-Feld. Ohne diese Zeile besteht ein
// solcher Token die Pruefung hier und gilt als vollwertige Anmeldung:
// das 2FA-Challenge-Cookie wird ausgestellt, NACHDEM das Passwort stimmt,
// aber BEVOR der zweite Faktor geprueft ist — wer es in das
// Session-Cookie kopiert, umgeht damit die Zwei-Faktor-Pruefung.
//
// Praktisch scheiterte das bisher nur zufaellig daran, dass diese Token
// keine sessionVersion tragen (also 0) und getCurrentCustomer die Version
// gegen die Datenbank prueft — wer 2FA aktiviert, steht dort bereits bei
// mindestens 1 (siehe konto/actions/two-factor.ts). Das ist eine Sperre,
// die aus einem voellig anderen Grund existiert; sie faellt lautlos weg,
// sobald sich an Zaehler oder Token etwas aendert.
//
// Das Session-Token selbst setzt kein "type" (siehe signSessionToken).
// Deshalb wird hier auf ABWESENHEIT geprueft statt auf einen bestimmten
// Wert: so bleiben alle bereits ausgestellten Cookies gueltig, niemand
// wird durch das Ausrollen abgemeldet.
if (payload.type !== undefined) return null;
const role: SessionRole = payload.role === "ADMIN" ? "ADMIN" : "KUNDE"; const role: SessionRole = payload.role === "ADMIN" ? "ADMIN" : "KUNDE";
const sessionVersion = typeof payload.sessionVersion === "number" ? payload.sessionVersion : 0; const sessionVersion = typeof payload.sessionVersion === "number" ? payload.sessionVersion : 0;
return { customerId: payload.sub, role, sessionVersion }; return { customerId: payload.sub, role, sessionVersion };
+16 -5
View File
@@ -1,8 +1,8 @@
// Zentrale, austauschbare Inhalte fuer Navigation, Footer und Firmendaten. // Zentrale, austauschbare Inhalte fuer Navigation, Footer und Firmendaten.
// email/phone sind noch keine echten Kontaktdaten (auf dem Etikett nicht // email ist noch keine echte Kontaktadresse (auf dem Etikett nicht abgebildet)
// abgebildet) und muessen vor dem Live-Gang ersetzt werden — bewusst ohne // und muss vor dem Live-Gang ersetzt werden — bewusst ohne sichtbaren
// sichtbaren "[PLATZHALTER]"-Text im String, da dieser sonst 1:1 im Footer // "[PLATZHALTER]"-Text im String, da dieser sonst 1:1 im Footer und auf der
// und auf der Kontaktseite mitgerendert wuerde. // Kontaktseite mitgerendert wuerde.
export const categoryLabels = { export const categoryLabels = {
HONIG: "Honig", HONIG: "Honig",
@@ -19,7 +19,18 @@ export const siteConfig = {
"Entdecke hochwertigen Honig aus eigener Imkerei in Altenmünster. Kleine Ernten, klare Herkunft und echtes Imkerhandwerk.", "Entdecke hochwertigen Honig aus eigener Imkerei in Altenmünster. Kleine Ernten, klare Herkunft und echtes Imkerhandwerk.",
location: "Altenmünster, Bayern", location: "Altenmünster, Bayern",
email: "[email protected]", email: "[email protected]",
phone: "+49 (0) 000 000000", /**
* Leer, und das ist die Vorgabe: eine Telefonnummer ist keine Pflichtangabe.
* § 5 TMG verlangt eine Moeglichkeit zur unmittelbaren Kontaktaufnahme —
* die E-Mail-Adresse erfuellt das.
*
* Frueher stand hier eine Platzhalternummer ("+49 (0) 000 000000"). Wer im
* Admin-Bereich keine Nummer pflegte, bekam sie auf der Kontaktseite und im
* Impressum tatsaechlich angezeigt — eine Nummer, unter der niemand zu
* erreichen ist, ist schlimmer als gar keine. Bleibt das Feld leer, wird die
* Zeile jetzt ueberall weggelassen (kontakt/page.tsx, impressum/page.tsx).
*/
phone: "",
address: { address: {
street: "Buhlweg 10", street: "Buhlweg 10",
zip: "86450", zip: "86450",
+7 -2
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { getContentValues } from "@/lib/content"; import { getContentValues } from "@/lib/content";
const SOCIAL_KEYS = ["social.tiktok", "social.youtube", "social.instagram"] as const; const SOCIAL_KEYS = ["social.tiktok", "social.youtube", "social.instagram"] as const;
@@ -13,12 +14,16 @@ export interface SocialLinks {
* Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer * Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer
* String bedeutet "kein Profil hinterlegt"; der Footer rendert dann kein * String bedeutet "kein Profil hinterlegt"; der Footer rendert dann kein
* Icon fuer diese Plattform (siehe components/site-footer.tsx Footer()). * Icon fuer diese Plattform (siehe components/site-footer.tsx Footer()).
*
* In React-cache() gehuellt, aus demselben Grund wie getCompanyInfo: die
* Deduplizierung gilt je Request, nicht serverweit — Aenderungen im
* Admin-Bereich wirken unveraendert sofort.
*/ */
export async function getSocialLinks(): Promise<SocialLinks> { export const getSocialLinks = cache(async (): Promise<SocialLinks> => {
const v = await getContentValues([...SOCIAL_KEYS], "de"); const v = await getContentValues([...SOCIAL_KEYS], "de");
return { return {
tiktok: v["social.tiktok"], tiktok: v["social.tiktok"],
youtube: v["social.youtube"], youtube: v["social.youtube"],
instagram: v["social.instagram"], instagram: v["social.instagram"],
}; };
} });
+45
View File
@@ -84,6 +84,51 @@ describe("checkoutSchema", () => {
const result = checkoutSchema.safeParse({ ...baseInput, discountCode: "SOMMER15" }); const result = checkoutSchema.safeParse({ ...baseInput, discountCode: "SOMMER15" });
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
// Die beiden Muster standen hier einmal als /^d{1,10}$/ bzw. /^d{6,10}$/ —
// ohne Backslash. Das traf nicht "Ziffer", sondern den Buchstaben d: jede
// echte Packstation- und Postnummer wurde abgewiesen, Lieferungen an eine
// Packstation waren damit vollstaendig unmoeglich. Der Fehler war aus dem
// Code nicht zu sehen, weil die Fehlermeldung daneben das Richtige sagte.
test("accepts a real packstation delivery", () => {
const result = checkoutSchema.safeParse({
...baseInput,
fulfillmentMethod: "VERSAND" as const,
packstationNumber: "145",
postNumber: "123456",
});
expect(result.success).toBe(true);
});
test("rejects letters in the packstation and post number", () => {
expect(
checkoutSchema.safeParse({
...baseInput,
fulfillmentMethod: "VERSAND" as const,
packstationNumber: "abc",
postNumber: "123456",
}).success
).toBe(false);
expect(
checkoutSchema.safeParse({
...baseInput,
fulfillmentMethod: "VERSAND" as const,
packstationNumber: "145",
postNumber: "dddddd",
}).success
).toBe(false);
});
test("requires the post number for a packstation delivery", () => {
// Ohne sie laesst sich spaeter keine Versandmarke erstellen — die
// Bestellung waere angenommen, aber nicht zustellbar.
const result = checkoutSchema.safeParse({
...baseInput,
fulfillmentMethod: "VERSAND" as const,
packstationNumber: "145",
});
expect(result.success).toBe(false);
});
}); });
describe("reviewSchema", () => { describe("reviewSchema", () => {
+2 -2
View File
@@ -178,13 +178,13 @@ export const checkoutSchema = z.object({
packstationNumber: z packstationNumber: z
.string() .string()
.trim() .trim()
.regex(/^d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.") .regex(/^\d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.")
.optional(), .optional(),
/** DHL-Postnummer der empfangenden Person — bei Packstation-Lieferung Pflicht. */ /** DHL-Postnummer der empfangenden Person — bei Packstation-Lieferung Pflicht. */
postNumber: z postNumber: z
.string() .string()
.trim() .trim()
.regex(/^d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.") .regex(/^\d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.")
.optional(), .optional(),
}) })
// DHL verlangt bei Lieferung an eine Packstation zwingend die Postnummer // DHL verlangt bei Lieferung an eine Packstation zwingend die Postnummer