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]>
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
import { z } from "zod";
|
|
|
|
// Gemeinsame, gehaertete Basisfelder: Laengenlimits verhindern uebergrosse
|
|
// Payloads in der Datenbank, trim/lowercase normalisieren E-Mail-Adressen,
|
|
// damit "[email protected]" und "[email protected] " dasselbe Konto treffen.
|
|
const emailSchema = z
|
|
.string()
|
|
.trim()
|
|
.toLowerCase()
|
|
.max(254, "Die E-Mail-Adresse ist zu lang.")
|
|
.email("Bitte gib eine gültige E-Mail-Adresse ein.");
|
|
|
|
const nameSchema = (message: string) => z.string().trim().min(1, message).max(100);
|
|
|
|
export const newsletterSchema = z.object({
|
|
email: emailSchema,
|
|
});
|
|
export type NewsletterInput = z.infer<typeof newsletterSchema>;
|
|
|
|
export const stockNotificationSchema = z.object({
|
|
productId: z.string().min(1).max(64),
|
|
email: emailSchema,
|
|
});
|
|
export type StockNotificationInput = z.infer<typeof stockNotificationSchema>;
|
|
|
|
export const returnRequestSchema = z.object({
|
|
orderId: z.string().min(1).max(64),
|
|
reason: z.string().trim().min(3, "Bitte gib einen Grund für die Rücksendung an.").max(2000),
|
|
});
|
|
export type ReturnRequestInput = z.infer<typeof returnRequestSchema>;
|
|
|
|
export const businessInquirySchema = z.object({
|
|
companyName: z.string().trim().min(2, "Bitte gib den Firmennamen an.").max(200),
|
|
contactPerson: z.string().trim().min(2, "Bitte gib einen Ansprechpartner an.").max(100),
|
|
email: emailSchema,
|
|
phone: z.string().trim().max(40).optional(),
|
|
desiredQuantity: z.coerce
|
|
.number()
|
|
.int()
|
|
.min(1, "Bitte gib eine Menge von mindestens 1 an.")
|
|
.max(100_000),
|
|
desiredDeliveryDate: z.string().max(30).optional(),
|
|
package: z.enum(["KLASSIK", "GESCHENK", "INDIVIDUELL"]),
|
|
message: z.string().trim().max(5000).optional(),
|
|
});
|
|
export type BusinessInquiryInput = z.infer<typeof businessInquirySchema>;
|
|
|
|
export const registerSchema = z.object({
|
|
firstName: nameSchema("Bitte gib deinen Vornamen an."),
|
|
lastName: nameSchema("Bitte gib deinen Nachnamen an."),
|
|
email: emailSchema,
|
|
// bcrypt verarbeitet maximal 72 Bytes — laengere Passwoerter wuerden sonst
|
|
// stillschweigend abgeschnitten, deshalb hartes Limit mit klarer Meldung.
|
|
password: z
|
|
.string()
|
|
.min(8, "Das Passwort muss mindestens 8 Zeichen lang sein.")
|
|
.max(72, "Das Passwort darf höchstens 72 Zeichen lang sein."),
|
|
});
|
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
|
|
|
export const loginSchema = z.object({
|
|
email: emailSchema,
|
|
password: z.string().min(1, "Bitte gib dein Passwort ein.").max(72),
|
|
});
|
|
export type LoginInput = z.infer<typeof loginSchema>;
|
|
|
|
export const requestPasswordResetSchema = z.object({
|
|
email: emailSchema,
|
|
});
|
|
export type RequestPasswordResetInput = z.infer<typeof requestPasswordResetSchema>;
|
|
|
|
export const resetPasswordSchema = z.object({
|
|
token: z.string().trim().min(1, "Ungültiger oder abgelaufener Link."),
|
|
password: z
|
|
.string()
|
|
.min(8, "Das Passwort muss mindestens 8 Zeichen lang sein.")
|
|
.max(72, "Das Passwort darf höchstens 72 Zeichen lang sein."),
|
|
});
|
|
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
|
|
|
export const adminCreateCustomerSchema = z.object({
|
|
firstName: nameSchema("Bitte gib den Vornamen an."),
|
|
lastName: nameSchema("Bitte gib den Nachnamen an."),
|
|
email: emailSchema,
|
|
role: z.enum(["KUNDE", "ADMIN"]),
|
|
});
|
|
export type AdminCreateCustomerInput = z.infer<typeof adminCreateCustomerSchema>;
|
|
|
|
export const adminUpdateCustomerSchema = z.object({
|
|
firstName: nameSchema("Bitte gib den Vornamen an."),
|
|
lastName: nameSchema("Bitte gib den Nachnamen an."),
|
|
email: emailSchema,
|
|
phone: z.string().trim().max(40).optional(),
|
|
role: z.enum(["KUNDE", "ADMIN"]),
|
|
});
|
|
export type AdminUpdateCustomerInput = z.infer<typeof adminUpdateCustomerSchema>;
|
|
|
|
// Nur die Zahlenfelder aus dem Admin-Produktformular — name/description/
|
|
// tasteTags/category/consistency laufen weiterhin ueber die einfachen
|
|
// str()/strOrNull()-Helfer in admin/actions/products.ts, da dort keine
|
|
// Vorzeichen-/Bereichspruefung noetig ist. num() dort prueft nur NaN/
|
|
// Infinity, nicht Vorzeichen — ohne dieses Schema waeren negative Preise/
|
|
// Gewichte/Intensitaeten ueber ein manipuliertes Formularfeld moeglich.
|
|
export const adminProductNumbersSchema = z.object({
|
|
intensityMild: z.coerce.number().int().min(1).max(5),
|
|
intensityFloral: z.coerce.number().int().min(1).max(5),
|
|
intensityKraeftig: z.coerce.number().int().min(1).max(5),
|
|
weightGrams: z.coerce.number().int().min(1, "Das Gewicht muss mindestens 1 g betragen."),
|
|
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
|
|
pricePerKgCents: z.coerce.number().int().min(0, "Der Kilopreis darf nicht negativ sein."),
|
|
quantity: z.coerce.number().int().min(0, "Der Lagerbestand darf nicht negativ sein."),
|
|
/**
|
|
* Umsatzsteuersatz als Ausnahme zur Kategorie. Leeres Feld = null = "Satz
|
|
* der Kategorie" (der Normalfall, siehe lib/vat.ts) — deshalb ein eigener
|
|
* Zweig fuer den Leerstring: z.coerce.number() machte daraus sonst eine 0,
|
|
* und 0 bedeutet hier "steuerfrei nach § 19 UStG", also etwas voellig
|
|
* anderes als "nicht gesetzt".
|
|
*/
|
|
vatRatePercent: z
|
|
.union([
|
|
z.literal("").transform(() => null),
|
|
z.coerce
|
|
.number()
|
|
.int("Der Steuersatz muss eine ganze Zahl sein.")
|
|
.min(0, "Der Steuersatz darf nicht negativ sein.")
|
|
.max(100, "Der Steuersatz darf höchstens 100 betragen."),
|
|
])
|
|
.default(null),
|
|
});
|
|
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
|
|
|
|
export const shippingTierSchema = z
|
|
.object({
|
|
minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."),
|
|
maxQuantity: z.coerce.number().int().min(1, "Die Höchstmenge muss mindestens 1 sein.").optional(),
|
|
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
|
|
})
|
|
.refine((data) => data.maxQuantity === undefined || data.maxQuantity >= data.minQuantity, {
|
|
message: "Die Höchstmenge muss größer oder gleich der Mindestmenge sein.",
|
|
path: ["maxQuantity"],
|
|
});
|
|
export type ShippingTierInput = z.infer<typeof shippingTierSchema>;
|
|
|
|
export const addressSchema = z.object({
|
|
label: z.string().trim().min(1, "Bitte gib eine Bezeichnung an.").max(60),
|
|
firstName: nameSchema("Bitte gib den Vornamen an."),
|
|
lastName: nameSchema("Bitte gib den Nachnamen an."),
|
|
street: z.string().trim().min(1, "Bitte gib die Straße an.").max(200),
|
|
zip: z.string().trim().min(4, "Bitte gib eine gültige Postleitzahl an.").max(10),
|
|
city: z.string().trim().min(1, "Bitte gib den Ort an.").max(100),
|
|
country: z.string().trim().max(2).default("DE"),
|
|
isDefault: z.boolean().default(false),
|
|
});
|
|
export type AddressInput = z.infer<typeof addressSchema>;
|
|
|
|
export const checkoutSchema = z.object({
|
|
email: emailSchema,
|
|
firstName: nameSchema("Bitte gib deinen Vornamen an."),
|
|
lastName: nameSchema("Bitte gib deinen Nachnamen an."),
|
|
phone: z.string().trim().max(40).optional(),
|
|
fulfillmentMethod: z.enum(["VERSAND", "ABHOLUNG"]),
|
|
street: z.string().trim().max(200).optional(),
|
|
zip: z.string().trim().max(10).optional(),
|
|
city: z.string().trim().max(100).optional(),
|
|
country: z.string().trim().max(2).default("DE"),
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
productId: z.string().min(1).max(64),
|
|
quantity: z.coerce.number().int().min(1).max(20),
|
|
})
|
|
)
|
|
.min(1, "Der Warenkorb ist leer.")
|
|
.max(50, "Zu viele verschiedene Positionen im Warenkorb."),
|
|
discountCode: z.string().trim().max(50).optional(),
|
|
giftMessage: z.string().trim().max(500, "Die Geschenknachricht darf höchstens 500 Zeichen lang sein.").optional(),
|
|
/** Nummer der ueber die DHL-Standortsuche gewaehlten Packstation (z.B. "145"). */
|
|
packstationNumber: z
|
|
.string()
|
|
.trim()
|
|
.regex(/^\d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.")
|
|
.optional(),
|
|
/** DHL-Postnummer der empfangenden Person — bei Packstation-Lieferung Pflicht. */
|
|
postNumber: z
|
|
.string()
|
|
.trim()
|
|
.regex(/^\d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.")
|
|
.optional(),
|
|
})
|
|
// DHL verlangt bei Lieferung an eine Packstation zwingend die Postnummer
|
|
// der empfangenden Person — ohne sie laesst sich spaeter keine
|
|
// Versandmarke erstellen, die Bestellung waere also nicht zustellbar.
|
|
.refine((data) => !data.packstationNumber || Boolean(data.postNumber), {
|
|
message: "Für die Lieferung an eine Packstation wird deine DHL-Postnummer benötigt.",
|
|
path: ["postNumber"],
|
|
})
|
|
// Eine Packstation ist ein Versandziel — bei Abholung ergibt sie keinen Sinn
|
|
// und wuerde sonst still mitgespeichert.
|
|
.refine((data) => !data.packstationNumber || data.fulfillmentMethod === "VERSAND", {
|
|
message: "Eine Packstation ist nur bei Versand möglich.",
|
|
path: ["packstationNumber"],
|
|
});
|
|
export type CheckoutInput = z.infer<typeof checkoutSchema>;
|
|
|
|
export const reviewSchema = z.object({
|
|
productId: z.string().min(1).max(64),
|
|
rating: z.coerce.number().int().min(1, "Bitte wähle eine Bewertung von 1 bis 5 Sternen.").max(5),
|
|
comment: z.string().trim().max(2000).optional(),
|
|
});
|
|
export type ReviewInput = z.infer<typeof reviewSchema>;
|
|
|
|
export const discountCodeSchema = z
|
|
.object({
|
|
code: z
|
|
.string()
|
|
.trim()
|
|
.min(3, "Der Code muss mindestens 3 Zeichen lang sein.")
|
|
.max(30)
|
|
.toUpperCase(),
|
|
type: z.enum(["PERCENT", "FIXED"]),
|
|
value: z.coerce.number().int().min(1, "Der Wert muss mindestens 1 sein."),
|
|
minOrderCents: z.coerce.number().int().min(0, "Der Mindestbestellwert darf nicht negativ sein.").default(0),
|
|
minQuantity: z.coerce.number().int().min(1, "Die Mindestmenge muss mindestens 1 sein.").optional(),
|
|
maxUses: z.coerce.number().int().min(1, "Das Nutzungslimit muss mindestens 1 sein.").optional(),
|
|
isActive: z.boolean().default(true),
|
|
validFrom: z.coerce.date().optional(),
|
|
validUntil: z.coerce.date().optional(),
|
|
})
|
|
.refine((data) => data.type !== "PERCENT" || data.value <= 100, {
|
|
message: "Ein Prozent-Rabatt darf höchstens 100 sein.",
|
|
path: ["value"],
|
|
})
|
|
.refine((data) => !data.validFrom || !data.validUntil || data.validFrom <= data.validUntil, {
|
|
message: "Das Enddatum muss nach dem Startdatum liegen.",
|
|
path: ["validUntil"],
|
|
});
|
|
export type DiscountCodeInput = z.infer<typeof discountCodeSchema>;
|