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]>
251 lines
11 KiB
YAML
251 lines
11 KiB
YAML
# Taegliche Sicherung der Datenbank.
|
|
#
|
|
# WARUM: 03-postgres.yaml haelt Bestellungen, Rechnungen, Kundenkonten und
|
|
# Chargendaten in einem einzelnen PersistentVolume ohne jede Sicherung. Geht
|
|
# dieses Volume verloren (Speicherfehler, versehentliches "kubectl delete
|
|
# pvc", ein misslungenes Cluster-Update), sind saemtliche Bestellungen und
|
|
# Rechnungen unwiederbringlich weg. Rechnungen unterliegen in Deutschland
|
|
# ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein
|
|
# Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem.
|
|
#
|
|
# Diese Sicherung liegt bewusst NUR im Cluster. Sie schuetzt gegen
|
|
# Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten
|
|
# Clusters oder Standorts. Fuer eine vollstaendige Absicherung die
|
|
# erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster
|
|
# herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung
|
|
# und Wiederherstellung").
|
|
#
|
|
# MYSQL STATT POSTGRES: laeuft die Installation mit DATABASE_PROVIDER=mysql
|
|
# (03-mysql.yaml), stattdessen das Image "mysql:8.4.11" verwenden und den
|
|
# pg_dump-Aufruf unten ersetzen durch:
|
|
# mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \
|
|
# --single-transaction --routines wabenhain | gzip -9 > "$file.partial"
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: wabenhain-db-backup
|
|
namespace: wabenhain
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
# storageClassName: "dein-storage-class"
|
|
resources:
|
|
requests:
|
|
# Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im
|
|
# niedrigen Megabyte-Bereich; 10Gi tragen die 30 Tage Aufbewahrung
|
|
# unten mit grossem Puffer.
|
|
storage: 10Gi
|
|
---
|
|
apiVersion: batch/v1
|
|
kind: CronJob
|
|
metadata:
|
|
name: wabenhain-db-backup
|
|
namespace: wabenhain
|
|
spec:
|
|
# 03:15 Uhr — ausserhalb der ueblichen Bestellzeiten. Die Uhrzeit richtet
|
|
# sich nach der Zeitzone des Clusters (meist UTC).
|
|
schedule: "15 3 * * *"
|
|
# Kein zweiter Lauf, solange der vorherige noch laeuft.
|
|
concurrencyPolicy: Forbid
|
|
successfulJobsHistoryLimit: 3
|
|
# Fehlgeschlagene Laeufe laenger aufheben als erfolgreiche: nur an ihnen
|
|
# laesst sich per "kubectl logs" nachvollziehen, warum eine Sicherung
|
|
# ausblieb.
|
|
failedJobsHistoryLimit: 5
|
|
# Faellt ein Lauf aus (Knoten weg, Cluster-Wartung), gilt er nach einer
|
|
# Stunde als verpasst statt ewig nachgeholt zu werden.
|
|
startingDeadlineSeconds: 3600
|
|
jobTemplate:
|
|
spec:
|
|
backoffLimit: 2
|
|
template:
|
|
spec:
|
|
restartPolicy: OnFailure
|
|
# 70 ist die UID/GID des "postgres"-Nutzers im offiziellen
|
|
# Alpine-Image; fsGroup gibt dem Job Schreibrecht auf dem
|
|
# gemounteten Backup-Volume (gleiche Begruendung wie beim
|
|
# uploads-Volume in 05-app-deployment.yaml).
|
|
securityContext:
|
|
runAsUser: 70
|
|
runAsGroup: 70
|
|
fsGroup: 70
|
|
containers:
|
|
- name: pg-dump
|
|
# Gleiche Hauptversion wie der Server in 03-postgres.yaml —
|
|
# ein aelteres pg_dump kann eine neuere Datenbank nicht lesen.
|
|
image: postgres:16.15-alpine
|
|
env:
|
|
- name: PGPASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: wabenhain-secrets
|
|
key: DB_PASSWORD
|
|
command: ["/bin/sh", "-c"]
|
|
args:
|
|
- |
|
|
set -eu
|
|
# pipefail ist hier das Wichtigste an diesem Skript:
|
|
# ohne es liefert "pg_dump | gzip" den Exit-Code von gzip.
|
|
# gzip gelingt auch dann, wenn pg_dump vorher abgebrochen
|
|
# ist — der Job galte als erfolgreich und im Verzeichnis
|
|
# laege eine abgeschnittene, beim Wiederherstellen
|
|
# wertlose Datei. Genau so entstehen Sicherungen, die erst
|
|
# im Ernstfall als unbrauchbar auffallen.
|
|
set -o pipefail
|
|
|
|
stamp="$(date -u +%Y-%m-%d_%H%M)"
|
|
target="/backup/wabenhain-${stamp}.sql.gz"
|
|
|
|
# Erst unter einem Zwischennamen schreiben, dann
|
|
# umbenennen: ein mitten im Lauf abgebrochener Job
|
|
# hinterlaesst so keine Datei, die wie eine vollstaendige
|
|
# Sicherung aussieht.
|
|
pg_dump --host=wabenhain-postgres --username=wabenhain \
|
|
--dbname=wabenhain --clean --if-exists \
|
|
| gzip -9 > "${target}.partial"
|
|
mv "${target}.partial" "${target}"
|
|
|
|
# Rechnungsablage mitsichern (INVOICE_DIR, siehe
|
|
# 04-uploads-pvc.yaml). Die Datenbank kennt von einer
|
|
# Rechnung nur Nummer, Betrag und Pfad — das ausgelieferte
|
|
# PDF liegt ausschliesslich auf dem Volume. Ein
|
|
# Datenbank-Dump allein ersetzt den Beleg also NICHT.
|
|
invoices="/backup/rechnungen-${stamp}.tar.gz"
|
|
tar -czf "${invoices}.partial" -C /invoices .
|
|
mv "${invoices}.partial" "${invoices}"
|
|
|
|
# Angefangene Dateien frueherer Abbrueche mit aufraeumen.
|
|
find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete
|
|
find /backup -name 'rechnungen-*.tar.gz.partial' -mtime +1 -delete
|
|
|
|
# 30 Tage Aufbewahrung. Achtung: ersetzt KEINE
|
|
# langfristige Archivierung der Rechnungen (§ 147 AO,
|
|
# zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig
|
|
# ausgelagerte Sicherungen nutzen.
|
|
find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete
|
|
find /backup -name 'rechnungen-*.tar.gz' -mtime +30 -delete
|
|
|
|
echo "Sicherung abgeschlossen:"
|
|
ls -lh /backup
|
|
resources:
|
|
requests:
|
|
cpu: 50m
|
|
memory: 128Mi
|
|
limits:
|
|
memory: 256Mi
|
|
volumeMounts:
|
|
- name: backup
|
|
mountPath: /backup
|
|
# Nur lesend: dieser Job darf Belege sichern, niemals aendern.
|
|
- name: invoices
|
|
mountPath: /invoices
|
|
readOnly: true
|
|
volumes:
|
|
- name: backup
|
|
persistentVolumeClaim:
|
|
claimName: wabenhain-db-backup
|
|
# ACHTUNG bei mehr als einem Knoten: wabenhain-invoices ist
|
|
# ReadWriteOnce (04-uploads-pvc.yaml). Ein solches Volume laesst
|
|
# sich nur von Pods auf DEMSELBEN Knoten einhaengen. Landet dieser
|
|
# Sicherungs-Pod auf einem anderen Knoten als der App-Pod, bleibt
|
|
# er in "Pending" haengen — sichtbar nur, wenn man nachsieht,
|
|
# denn ein Pending-Pod meldet keinen Fehler.
|
|
#
|
|
# Auf einem Ein-Knoten-Cluster ist das kein Thema. Bei mehreren
|
|
# Knoten das PVC auf ReadWriteMany umstellen (NFS/SMB/CephFS) —
|
|
# dann greift die Einschraenkung nicht mehr.
|
|
- name: invoices
|
|
persistentVolumeClaim:
|
|
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
|