Files
Wabenhain/scripts/preflight.ts
T
Nick SzaunigandClaude Opus 5 7c59283ede feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.

DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.

Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.

Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.

Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.

Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.

Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:32:12 +02:00

238 lines
10 KiB
TypeScript

/**
* Go-Live-Pruefung: faellt laut durch, statt den Shop still mit
* Platzhaltern oder Test-Zugangsdaten oeffentlich zu stellen.
*
* Aufruf: `pnpm preflight` (liest .env) — gedacht als letzter Schritt vor
* dem ersten oeffentlichen Deploy und als Schritt in der Deploy-Pipeline.
* Endet mit Exit-Code 1, sobald ein Blocker offen ist.
*
* Die Inhaltspruefungen fragen bewusst die DATENBANK und nicht den
* Quelltext: Impressum, Datenschutz, AGB, Widerrufsbelehrung und die
* Telefonnummer sind im Admin-Bereich unter "Inhalte" pflegbar (siehe
* lib/content-registry.ts). Die Texte in i18n/dictionaries/ sind nur der
* Rueckfallwert, solange dort nichts gepflegt ist. Eine Pruefung, die nur
* in den Quelltext schaut, meldet deshalb einen Blocker, den die
* Betreiberin im Admin laengst geschlossen hat — und schweigt umgekehrt,
* wenn jemand einen Platzhaltertext erst im Admin eintraegt.
*
* Ist die Datenbank nicht erreichbar (Aufruf von einer Maschine ohne
* Zugang zur Produktionsdatenbank), werden diese Pruefungen ausdruecklich
* als ungeprueft gemeldet statt geraten.
*/
import "dotenv/config";
import { prisma } from "../src/lib/prisma";
import { getContentValue } from "../src/lib/content";
import { locales } from "../src/i18n/config";
const blockers: string[] = [];
const warnings: string[] = [];
const passed: string[] = [];
function must(label: string, ok: boolean, hint: string): void {
if (ok) passed.push(label);
else blockers.push(`${label}\n ${hint}`);
}
function should(label: string, ok: boolean, hint: string): void {
if (ok) passed.push(label);
else warnings.push(`${label}\n ${hint}`);
}
const value = (name: string): string => (process.env[name] ?? "").trim();
const isSet = (name: string): boolean => value(name).length > 0;
// --- Sitzungs-/Zugangsgeheimnisse -----------------------------------------
const sessionSecret = value("SESSION_SECRET");
must(
"SESSION_SECRET gesetzt und mindestens 32 Zeichen lang",
sessionSecret.length >= 32,
"Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32"
);
must(
"SESSION_SECRET ist nicht mehr der Beispielwert",
!sessionSecret.includes("change-me"),
"Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar."
);
const adminEmail = value("ADMIN_EMAIL");
must(
"ADMIN_EMAIL gesetzt und kein Beispielwert",
adminEmail.length > 0 && !adminEmail.includes("change-me") && !adminEmail.endsWith("@example.com"),
'Legt das erste Admin-Konto an (prisma db seed). Eine unvorhersehbare Adresse waehlen, nicht "admin@...".'
);
must(
"ADMIN_PASSWORD gesetzt, kein Beispielwert, mindestens 12 Zeichen",
value("ADMIN_PASSWORD").length >= 12 && !value("ADMIN_PASSWORD").includes("change-me"),
"Dieses Konto darf Rueckerstattungen ausloesen und alle Kundendaten einsehen."
);
// --- Oeffentliche Adresse der Seite ---------------------------------------
const siteUrl = value("NEXT_PUBLIC_SITE_URL");
must(
"NEXT_PUBLIC_SITE_URL gesetzt",
siteUrl.length > 0,
"Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)."
);
must(
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
siteUrl.startsWith("https://") && !siteUrl.includes("localhost"),
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
);
must(
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
!siteUrl.includes("example.com") && !siteUrl.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.'
);
// --- Zahlung ---------------------------------------------------------------
must(
"STRIPE_SECRET_KEY gesetzt",
isSet("STRIPE_SECRET_KEY"),
"Ohne Stripe-Schluessel schlaegt jeder Checkout fehl — der Shop nimmt keine Bestellung an."
);
must(
"STRIPE_WEBHOOK_SECRET gesetzt",
isSet("STRIPE_WEBHOOK_SECRET"),
'Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.'
);
must(
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
!value("STRIPE_SECRET_KEY").startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
);
// --- E-Mail ----------------------------------------------------------------
must(
"SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt",
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."
);
should(
"SMTP_FROM zeigt auf eine eigene Domain",
!value("SMTP_FROM").includes("example.com"),
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
);
// --- Umsatzsteuer ----------------------------------------------------------
// Ein falscher Steuersatz faellt im Betrieb kaum auf: die Rechnung sieht
// plausibel aus, nur die ausgewiesene Steuer stimmt nicht. Deshalb hier
// ausdruecklich pruefen, statt sich auf den Rueckfallwert zu verlassen.
const vatRate = value("VAT_RATE_PERCENT");
const vatRateNumber = Number(vatRate);
must(
"VAT_RATE_PERCENT gesetzt und ein ganzzahliger Prozentwert",
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."
);
should(
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
["0", "7", "19"].includes(vatRate),
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
);
// --- Datenbank -------------------------------------------------------------
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
// --- Missbrauchsschutz -----------------------------------------------------
should(
"Cloudflare Turnstile konfiguriert",
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
"Ohne Turnstile laufen Registrierung, Newsletter und Firmenanfrage ohne CAPTCHA — bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege."
);
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
// Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER",
// die englische "[PLACEHOLDER". Eine Pruefung auf nur eine von beiden liesse
// die jeweils andere Sprachfassung mit Luecken online gehen.
/**
* Erste aussagekraeftige Zeile einer Fehlermeldung. Prisma-Verbindungsfehler
* beginnen mit Leerzeilen — ein blosses split("\n")[0] ergaebe eine leere
* Klammer statt der Ursache.
*/
function firstMeaningfulLine(error: unknown): string {
if (!(error instanceof Error)) return "unbekannter Fehler";
const line = error.message.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
return line ?? error.name;
}
const PLACEHOLDER_MARKERS = ["[PLATZHALTER", "[PLACEHOLDER"];
const hasPlaceholder = (text: string): boolean =>
PLACEHOLDER_MARKERS.some((marker) => text.includes(marker));
const LEGAL_KEYS = [
{ key: "legal.impressum", label: "Impressum" },
{ key: "legal.datenschutz", label: "Datenschutz" },
{ key: "legal.agb", label: "AGB" },
{ key: "legal.widerruf", label: "Widerrufsbelehrung" },
] as const;
// Kein Top-Level-await: das Projekt hat kein "type": "module" in der
// package.json, tsx uebersetzt dieses Skript daher nach CommonJS.
async function checkDatabaseContent(): Promise<void> {
try {
// Verbindung zuerst pruefen, damit ein DB-Ausfall unten nicht faelschlich
// als "Inhalt in Ordnung" durchgeht.
await prisma.$queryRaw`SELECT 1`;
const withPlaceholder: string[] = [];
for (const { key, label } of LEGAL_KEYS) {
for (const locale of locales) {
if (hasPlaceholder(await getContentValue(key, locale))) {
withPlaceholder.push(`${label} (${locale})`);
}
}
}
must(
"Rechtstexte enthalten keine Platzhalter-Markierungen mehr",
withPlaceholder.length === 0,
`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.`
);
should(
"Telefonnummer ist keine Platzhalternummer",
!(await getContentValue("company.phone", "de")).includes("000 000000"),
'Sichtbar im Footer, auf der Kontaktseite und im Impressum. Im Admin-Bereich unter "Inhalte" pflegbar.'
);
} catch (error: unknown) {
// Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst
// sich der tatsaechlich ausgelieferte Text nicht feststellen.
warnings.push(
`Rechtstexte und Telefonnummer konnten NICHT geprueft werden\n Die Datenbank ist von hier aus nicht erreichbar (${firstMeaningfulLine(
error
)}). Diese Inhalte liegen in der Datenbank, nicht im Quelltext — die Pruefung mit Zugriff auf die Produktionsdatenbank wiederholen oder /impressum im Browser aufrufen.`
);
} finally {
await prisma.$disconnect();
}
}
function report(): void {
// --- Ausgabe ---------------------------------------------------------------
const line = "─".repeat(72);
console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`);
console.log(`\n✔ Bestanden: ${passed.length}`);
for (const item of passed) console.log(` ✔ ${item}`);
if (warnings.length > 0) {
console.log(`\n⚠ Warnungen: ${warnings.length}`);
for (const item of warnings) console.log(` ⚠ ${item}`);
}
if (blockers.length > 0) {
console.log(`\n✖ Blocker: ${blockers.length}`);
for (const item of blockers) console.log(` ✖ ${item}`);
console.log(`\n${line}`);
console.log("Nicht bereit fuer den Live-Betrieb. Die Blocker oben zuerst beheben.\n");
process.exit(1);
}
console.log(`\n${line}`);
console.log(
warnings.length > 0
? "Keine Blocker. Warnungen oben pruefen, dann ist der Live-Betrieb moeglich.\n"
: "Alle Pruefungen bestanden — bereit fuer den Live-Betrieb.\n"
);
}
void checkDatabaseContent().then(report);