Zahlungsarten überarbeitet: echtes PayPal, Klarna & eigene Kartenmaske raus

Auf ausdrücklichen Wunsch, mit Fokus auf rechtliche Absicherung:

- Klarna komplett entfernt (hätte eigene Händlerprüfung + Bonitätsprüfungs-
  Pflichten nach der neuen EU-Verbraucherkreditrichtlinie vorausgesetzt).
- Eigene Kreditkarten-Eingabemaske komplett entfernt (wäre PCI-DSS-pflichtig
  gewesen — für einen kleinen Shop praktisch nicht stemmbar). Kartenzahlung
  bleibt möglich: PayPals eigener, PCI-zertifizierter Gast-Checkout bietet
  Kredit-/Debitkarte an, ohne dass Kartendaten je unsere Seite berühren.
- PayPal ist jetzt ECHT server-seitig verifiziert statt dem Browser blind zu
  vertrauen: eigener Cloudflare-Function-Flow (functions/_shared/paypal.js +
  functions/api/paypal/) legt die PayPal-Bestellung server-seitig an, zieht
  die Zahlung nach Bestätigung server-seitig ein und prüft den eingezogenen
  Betrag gegen die Bestellsumme — die Bestellung wird ausschließlich bei
  bestätigter, betragsgleicher Zahlung angelegt. /api/orders lehnt direkte
  PayPal-Bestellungen jetzt ausdrücklich ab (verhindert vorgetäuschte
  "bezahlte" Bestellungen ohne echte Zahlung).
- Neuer, ehrlicherer Bestellstatus "zahlungOffen": Überweisungs-Bestellungen
  starten jetzt so (Geld noch nicht da) statt fälschlich sofort "bezahlt" zu
  heißen — Schutz vor Warenversand, bevor das Geld wirklich angekommen ist.
  Eigene Kachel/Filter/Badge-Farbe in der Verwaltung, Umsatz-/Auswertungs-
  Zahlen zählen "zahlungOffen" bewusst nicht mit.
- Rechtstexte (AGB, Datenschutzerklärung, FAQ, Versand & Zahlung) auf allen
  4 Sprachen aktualisiert: nur noch PayPal + Überweisung erwähnt, inkl. DSGVO-
  Hinweis zur internationalen Datenübertragung an PayPal (Data Privacy
  Framework-Zertifizierung).
- D1-Migration 0003: neue Spalte paypal_order_id (Zahlungsbeleg) + erweiterter
  Status-Wertebereich, auf Live-Datenbank angewendet, bestehende Daten intakt.

Mit echten Testbestellungen lokal verifiziert: Überweisung legt korrekt
"zahlungOffen" an, direkter PayPal-Bypass-Versuch an /api/orders wird
abgelehnt, PayPal-Route meldet sauber "noch nicht eingerichtet" ohne
Zugangsdaten. Checkout-UI zeigt nur noch 2 Zahlungsarten, Bestellknopf ist bei
PayPal ausgeblendet (Zahlung läuft exklusiv über den echten PayPal-Button).

Für den echten Zahlungseingang fehlt noch VanVans eigenes PayPal-Business-
Konto (Client-ID + Secret) — Details in der Vault-Dokumentation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-04 01:06:41 +02:00
co-authored by Claude Sonnet 5
parent 98028df4ea
commit 81c175d03c
18 changed files with 1077 additions and 743 deletions
+25 -183
View File
@@ -1,69 +1,24 @@
/* =====================================================================
POST /api/orders — legt eine echte Bestellung in D1 an (orders + order_items).
POST /api/orders — legt eine Überweisungs-Bestellung (Vorkasse) in D1 an.
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand die
Bestellung abschickt. BEWUSST nicht dauerhaft hinter dem Zugangscode-Gate (siehe
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand mit
"Banküberweisung" bestellt. BEWUSST nicht dauerhaft hinter dem Zugangscode-Gate (siehe
functions/_middleware.js) — sobald der Shop öffentlich ist (SITE_PUBLIC=true), müssen auch
echte Kund:innen hier bestellen können, genau wie sie den Checkout selbst erreichen können.
Vor dem Live-Gang ist ohnehin die ganze Seite inkl. Checkout hinter dem Gate, d.h. nur
qciga/VanVan können bis hierhin überhaupt kommen.
echte Kund:innen hier bestellen können.
In Phase 1 gibt es kein echtes Zahlungs-Backend — jede vollständig abgeschickte Bestellung
gilt sofort als "bezahlt" (siehe scripts/account.ts, derselbe Grundsatz). ===================================================================== */
WICHTIG (seit der echten PayPal-Anbindung): Diese Route legt Bestellungen NUR noch mit dem
Status "zahlungOffen" an (Geld ist noch nicht da) und lehnt zahlungsart:"paypal" AUSDRÜCKLICH
ab. Grund: PayPal-Bestellungen dürfen NUR über functions/api/paypal/capture-order.js entstehen,
nachdem die Zahlung dort server-seitig bei PayPal selbst bestätigt wurde — sonst könnte jemand
diese Route einfach direkt mit zahlungsart:"paypal" aufrufen und eine "bezahlte" Bestellung
vortäuschen, ohne je wirklich zu bezahlen (echtes Betrugs-/Verlustrisiko für VanVan). ===================================================================== */
import { json } from "../_shared/http.js";
import { sendeEmail, escapeHtmlFuerEmail } from "../_shared/email.js";
// Gleiche Gutschein-Daten wie im Frontend (src/data/gutscheine.ts) — hier server-seitig erneut
// eingebunden, um das "nur einmal pro Person"-Flag unabhängig vom Browser durchzusetzen (siehe
// unten). Bewusst derselbe JSON-Rohdaten-Import, keine Code-Duplizierung der Inhalte.
// @ts-ignore
import gutscheineRaw from "../../src/content/gutscheine.json";
const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"];
const gutscheine = (gutscheineRaw && gutscheineRaw.codes) || [];
// VanVans Wunsch-Adresse für Bestellbenachrichtigungen (siehe Anfrage vom 03.08.2026). Fest
// hinterlegt statt als Secret, da es keine geheime Information ist (nur ein Empfänger).
const BENACHRICHTIGUNGS_EMAIL = "[email protected]";
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
function formatPreis(n) {
return `${Number(n).toFixed(2).replace(".", ",")}`;
}
/** Baut die interne "neue Bestellung eingegangen"-E-Mail an VanVan/qciga — bewusst schlicht
* gehalten (kein Marketing-Layout nötig, nur die wichtigsten Infos auf einen Blick + Link zur
* Verwaltung). */
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }) {
const artikelZeilen = artikel
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)}${formatPreis(a.preis * a.menge)}</li>`)
.join("");
return `
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
<p><strong>Artikel:</strong></p>
<ul>${artikelZeilen}</ul>
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
<p><a href="https://vans-diy-bastelbedarf.pages.dev/verwaltung/">→ Zur Bestellverwaltung</a></p>
</div>
`;
}
function findeGutschein(code) {
const normalisiert = String(code || "").trim().toUpperCase();
if (!normalisiert) return null;
return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null;
}
import { erstelleBestellung } from "../_shared/bestellung-erstellen.js";
export async function onRequestPost(context) {
const { request, env } = context;
if (!env.DB) {
return json(500, { ok: false, error: "Datenbank nicht verbunden." });
}
let body;
try {
body = await request.json();
@@ -71,133 +26,20 @@ export async function onRequestPost(context) {
return json(400, { ok: false, error: "Ungültige Anfrage." });
}
// ── Validierung: lieber eine klare Fehlermeldung im Checkout als eine kaputte/leere
// Bestellung in der Datenbank. ──
const land = String(body.land || "").toLowerCase();
if (!ERLAUBTE_LAENDER.includes(land)) {
return json(400, { ok: false, error: "Ungültiges Land." });
}
const kunde = body.kunde && typeof body.kunde === "object" ? body.kunde : {};
const name = String(kunde.name || "").trim();
const email = String(kunde.email || "").trim();
if (!name || !email || !email.includes("@")) {
return json(400, { ok: false, error: "Ungültige Kundendaten." });
}
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
if (artikel.length === 0) {
return json(400, { ok: false, error: "Die Bestellung enthält keine Artikel." });
}
for (const a of artikel) {
if (!a || typeof a.slug !== "string" || !a.slug.trim() || typeof a.name !== "string" || !a.name.trim()) {
return json(400, { ok: false, error: "Ungültige Artikeldaten." });
}
if (!Number.isFinite(a.menge) || a.menge <= 0) {
return json(400, { ok: false, error: "Ungültige Artikelmenge." });
}
if (!Number.isFinite(a.preis) || a.preis < 0) {
return json(400, { ok: false, error: "Ungültiger Artikelpreis." });
}
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe < 0) {
return json(400, { ok: false, error: "Ungültige Bestellsumme." });
}
const zwischensumme = Number.isFinite(Number(body.zwischensumme)) ? Number(body.zwischensumme) : 0;
const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0;
const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0;
// ── "Nur einmal pro Person" durchsetzen — unabhängig vom Browser/Gerät, da hier gegen die
// echten, gespeicherten Bestellungen geprüft wird (siehe d1/schema.sql, Spalte
// "gutschein_code"). Stornierte Bestellungen zählen NICHT als "schon benutzt" — wurde ja
// letztlich nicht wirksam eingelöst. ──
const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : "";
let gutscheinCodeNormalisiert = null;
if (gutscheinCodeRoh) {
const gutschein = findeGutschein(gutscheinCodeRoh);
gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase();
if (gutschein && gutschein.einmaligProPerson) {
const { results } = await env.DB.prepare(
`SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'`
)
.bind(gutscheinCodeNormalisiert, email)
.all();
if ((results?.[0]?.anzahl ?? 0) > 0) {
return json(400, {
ok: false,
error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.",
});
}
}
}
const now = new Date().toISOString();
try {
const insertOrder = await env.DB.prepare(
`INSERT INTO orders
(created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz, ort,
zahlungsart, zwischensumme, versandkosten, rabatt_gesamt, summe,
treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.bind(
now,
now,
"bezahlt",
name,
email,
land,
String(kunde.strasse || ""),
String(kunde.plz || ""),
String(kunde.ort || ""),
String(body.zahlungsart || ""),
zwischensumme,
versandkosten,
rabattGesamt,
summe,
body.treuebonusVerwendet ? 1 : 0,
body.aboRabattVerwendet ? 1 : 0,
gutscheinCodeNormalisiert
)
.run();
const orderId = insertOrder.meta.last_row_id;
// Menschenlesbare Bestellnummer wird ERST NACH dem Insert aus der autoinkrementierten,
// garantiert eindeutigen ID abgeleitet — keine Race Condition bei zwei gleichzeitigen
// Bestellungen (anders als z.B. "COUNT(*) + 1" vorher zu berechnen).
const jahr = new Date().getFullYear();
const bestellnummer = `VDB-${jahr}-${String(orderId).padStart(4, "0")}`;
await env.DB.prepare(`UPDATE orders SET bestellnummer = ? WHERE id = ?`).bind(bestellnummer, orderId).run();
const itemStmts = artikel.map((a) =>
env.DB.prepare(
`INSERT INTO order_items (order_id, produkt_slug, name, kategorie_slug, menge, preis, gratis)
VALUES (?,?,?,?,?,?,?)`
).bind(orderId, a.slug, a.name, a.kategorie || null, Math.round(a.menge), a.preis, a.gratis ? 1 : 0)
);
await env.DB.batch(itemStmts);
// E-Mail-Benachrichtigung an VanVan/qciga — wird bewusst ABGEWARTET (nicht per
// context.waitUntil im Hintergrund verschickt): ein Test am 03.08.2026 hat gezeigt, dass
// waitUntil() in dieser Pages-Functions-Umgebung nicht zuverlässig zu Ende lief, bevor die
// Anfrage beendet wurde (die Bestellung wurde gespeichert, aber die E-Mail kam nie an). Die
// paar hundert Millisekunden zusätzliche Wartezeit beim Abschicken der Bestellung sind ein
// kleiner Preis für "die E-Mail kommt garantiert an". Trotzdem weiterhin NICHT
// fehlerkritisch: sendeEmail() wirft nie einen Fehler, schlägt der Versand fehl (z.B. weil
// kein RESEND_API_KEY hinterlegt ist), bleibt die Bestellung trotzdem ganz normal gespeichert
// und in /verwaltung/ sichtbar — nur die Benachrichtigung fehlt dann eben.
await sendeEmail(env, {
to: BENACHRICHTIGUNGS_EMAIL,
subject: `🩵 Neue Bestellung ${bestellnummer}`,
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }),
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`,
const zahlungsart = String(body.zahlungsart || "");
if (zahlungsart === "paypal") {
return json(400, {
ok: false,
error: "PayPal-Bestellungen laufen über den PayPal-Button im Checkout, nicht über diese Route.",
});
return json(201, { ok: true, id: orderId, bestellnummer });
} catch (err) {
return json(500, { ok: false, error: "Bestellung konnte nicht gespeichert werden." });
}
if (zahlungsart !== "ueberweisung") {
return json(400, { ok: false, error: "Ungültige oder nicht unterstützte Zahlungsart." });
}
const ergebnis = await erstelleBestellung(env, body, { status: "zahlungOffen" });
if (!ergebnis.ok) {
return json(ergebnis.httpStatus || 500, { ok: false, error: ergebnis.error });
}
return json(201, { ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
}