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
+191
View File
@@ -0,0 +1,191 @@
/* =====================================================================
functions/_shared/bestellung-erstellen.js — die eigentliche "Bestellung anlegen"-Logik,
herausgelöst aus functions/api/orders.js, damit sie von ZWEI Stellen sicher wiederverwendet
werden kann:
1. functions/api/orders.js — für Überweisung (Vorkasse), Status "zahlungOffen".
2. functions/api/paypal/capture-order.js — für PayPal, aber NUR nachdem die Zahlung
server-seitig bei PayPal selbst als abgeschlossen bestätigt wurde (siehe
functions/_shared/paypal.js) — Status dann "bezahlt".
Gibt IMMER ein Ergebnisobjekt zurück ({ ok, ... } oder { ok:false, httpStatus, error }), NIE
direkt eine Response — die aufrufende Route entscheidet selbst, wie sie antwortet. ===================================================================== */
import { sendeEmail, escapeHtmlFuerEmail } from "./email.js";
// @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. 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(".", ",")}`;
}
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }) {
const artikelZeilen = artikel
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)}${formatPreis(a.preis * a.menge)}</li>`)
.join("");
const statusZeile =
status === "zahlungOffen"
? `<p style="color:#b06a00;"><strong>⏳ Zahlung noch ausstehend</strong> — Überweisung, bitte Zahlungseingang abwarten, bevor die Ware verschickt wird.</p>`
: `<p style="color:#1f9254;"><strong>✅ Zahlung bestätigt</strong> (PayPal)</p>`;
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>
${statusZeile}
<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;
}
/**
* Validiert die Bestelldaten, legt sie in D1 an und verschickt die Benachrichtigungsmail.
* @param {*} env Cloudflare-Umgebung (env.DB etc.)
* @param {*} body Roh-Payload vom Checkout (gleiches Format wie bisher)
* @param {{status: "zahlungOffen"|"bezahlt", paypalOrderId?: string|null}} optionen
*/
export async function erstelleBestellung(env, body, optionen) {
const { status, paypalOrderId = null } = optionen;
if (!env.DB) {
return { ok: false, httpStatus: 500, error: "Datenbank nicht verbunden." };
}
const land = String(body.land || "").toLowerCase();
if (!ERLAUBTE_LAENDER.includes(land)) {
return { ok: false, httpStatus: 400, 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 { ok: false, httpStatus: 400, error: "Ungültige Kundendaten." };
}
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
if (artikel.length === 0) {
return { ok: false, httpStatus: 400, 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 { ok: false, httpStatus: 400, error: "Ungültige Artikeldaten." };
}
if (!Number.isFinite(a.menge) || a.menge <= 0) {
return { ok: false, httpStatus: 400, error: "Ungültige Artikelmenge." };
}
if (!Number.isFinite(a.preis) || a.preis < 0) {
return { ok: false, httpStatus: 400, error: "Ungültiger Artikelpreis." };
}
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe < 0) {
return { ok: false, httpStatus: 400, 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. Stornierte Bestellungen zählen NICHT als
// "schon benutzt". ──
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 {
ok: false,
httpStatus: 400,
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, paypal_order_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.bind(
now,
now,
status,
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,
paypalOrderId
)
.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.
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 — bewusst abgewartet (siehe functions/_shared/email.js), aber
// NICHT fehlerkritisch: schlägt sie fehl, bleibt die Bestellung trotzdem ganz normal
// gespeichert.
await sendeEmail(env, {
to: BENACHRICHTIGUNGS_EMAIL,
subject: `${status === "zahlungOffen" ? "⏳" : "🩵"} Neue Bestellung ${bestellnummer}`,
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }),
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Status: ${status === "zahlungOffen" ? "Zahlung ausstehend (Überweisung)" : "Bezahlt (PayPal)"}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`,
});
return { ok: true, id: orderId, bestellnummer };
} catch (err) {
return { ok: false, httpStatus: 500, error: "Bestellung konnte nicht gespeichert werden." };
}
}
+103
View File
@@ -0,0 +1,103 @@
/* =====================================================================
functions/_shared/paypal.js — echte, server-seitige PayPal-Anbindung (REST API v2).
WARUM SERVER-SEITIG UND NICHT NUR IM BROWSER: Die vorherige Lösung ließ den Browser die
Zahlung direkt mit PayPal abwickeln (`actions.order.capture()`) und hat dem Ergebnis blind
vertraut — wer wollte, hätte den JavaScript-Aufruf einfach überspringen und trotzdem eine
"erfolgreiche" Bestellung auslösen können, OHNE je wirklich zu bezahlen. Das wäre ein echtes
Betrugsrisiko für VanVan gewesen (Ware raus, kein Geld rein).
Jetzt läuft die komplette Zahlungsprüfung hier auf dem Server:
1. functions/api/paypal/create-order.js legt die PayPal-Bestellung server-seitig an (der
Betrag kommt also von UNS, nicht vom Browser).
2. functions/api/paypal/capture-order.js fragt NACH der PayPal-Bestätigung direkt bei PayPal
selbst nach: ist die Zahlung wirklich abgeschlossen (`status === "COMPLETED"`) und stimmt
der tatsächlich eingezogene Betrag mit der Bestellsumme überein? Nur wenn beides zutrifft,
wird die Bestellung in der Datenbank als "bezahlt" angelegt.
Zugangsdaten: env.PAYPAL_CLIENT_ID / env.PAYPAL_CLIENT_SECRET (Cloudflare-Pages-Secrets,
niemals im Code). env.PAYPAL_ENV steuert Sandbox ("sandbox", Standard/Test) vs. Live-Betrieb
("live") — sobald VanVan ein echtes PayPal-Business-Konto hat, reicht das Setzen der echten
Live-Zugangsdaten + PAYPAL_ENV=live, ohne Codeänderung.
===================================================================== */
function paypalBasisUrl(env) {
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
}
/** Prüft, ob PayPal überhaupt eingerichtet ist — solange nicht, sollen alle PayPal-Routen sauber
* einen klaren Fehler liefern statt kryptisch gegen "undefined"-Zugangsdaten zu laufen. */
export function paypalKonfiguriert(env) {
return !!(env.PAYPAL_CLIENT_ID && env.PAYPAL_CLIENT_SECRET);
}
/** Holt ein kurzlebiges OAuth-Zugangstoken von PayPal (Client-Credentials-Flow, Standard-REST-
* API-Authentifizierung — kein Nutzer-Login nötig, das ist reine Server-zu-Server-Kommunikation
* mit VanVans eigenen App-Zugangsdaten). */
async function holeZugangstoken(env) {
const res = await fetch(`${paypalBasisUrl(env)}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${btoa(`${env.PAYPAL_CLIENT_ID}:${env.PAYPAL_CLIENT_SECRET}`)}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PayPal-Zugangstoken konnte nicht geholt werden (Status ${res.status}): ${text}`);
}
const data = await res.json();
return data.access_token;
}
/** Legt eine echte PayPal-Bestellung an — der Betrag kommt bewusst von UNS (server-seitig
* berechnet aus dem Warenkorb), nicht vom Browser, damit er beim Bezahlen bei PayPal nicht
* manipuliert werden kann. Gibt die PayPal-Bestell-ID zurück, die der Browser dann dem
* PayPal-Smart-Button übergibt. */
export async function erstellePaypalBestellung(env, betragEuro) {
const token = await holeZugangstoken(env);
const res = await fetch(`${paypalBasisUrl(env)}/v2/checkout/orders`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [{ amount: { currency_code: "EUR", value: Math.max(0.01, betragEuro).toFixed(2) } }],
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PayPal-Bestellung konnte nicht angelegt werden (Status ${res.status}): ${text}`);
}
const data = await res.json();
return data.id;
}
/** Zieht die Zahlung für eine zuvor angelegte PayPal-Bestellung tatsächlich ein und prüft das
* Ergebnis direkt bei PayPal (nicht nur, was der Browser behauptet). Gibt
* { ok, captureId, betragEuro } zurück — "ok" ist NUR true, wenn PayPal selbst "COMPLETED"
* bestätigt. Wirft nie einen Fehler nach außen (siehe Muster in functions/_shared/email.js),
* der Aufrufer prüft einfach `.ok`. */
export async function erfassePaypalZahlung(env, paypalOrderId) {
try {
const token = await holeZugangstoken(env);
const res = await fetch(`${paypalBasisUrl(env)}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}/capture`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data) {
return { ok: false, error: `PayPal-Zahlung konnte nicht erfasst werden (Status ${res.status}).` };
}
// Manche Zahlungen (z.B. bei zusätzlicher Prüfung durch die Bank) landen nicht sofort auf
// COMPLETED — das behandeln wir bewusst als "nicht erfolgreich", damit nie eine Bestellung
// als bezahlt gilt, obwohl das Geld noch nicht sicher zugesagt ist.
const capture = data?.purchase_units?.[0]?.payments?.captures?.[0];
if (data.status !== "COMPLETED" || !capture || capture.status !== "COMPLETED") {
return { ok: false, error: `PayPal-Zahlung ist nicht abgeschlossen (Status: ${data.status}).` };
}
return { ok: true, captureId: capture.id, betragEuro: Number(capture.amount?.value ?? 0) };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
+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 });
}
+81
View File
@@ -0,0 +1,81 @@
/* =====================================================================
POST /api/paypal/capture-order — zieht eine zuvor angelegte PayPal-Zahlung EIN und legt die
Bestellung erst danach an.
Das ist der entscheidende Sicherheits-Schritt der ganzen PayPal-Anbindung: Der Browser meldet
hier "die Kundin hat bei PayPal auf Bezahlen geklickt" (`onApprove`-Callback), aber wir
glauben das NICHT einfach — stattdessen fragen wir direkt bei PayPal selbst nach (server-zu-
server, mit VanVans eigenen App-Zugangsdaten), ob die Zahlung wirklich abgeschlossen ist UND
ob der eingezogene Betrag zur Bestellsumme passt. Nur wenn beides stimmt, wird die Bestellung
in der Datenbank angelegt — mit Status "bezahlt" und der echten PayPal-Bestell-ID als Beleg.
Schlägt die Zahlungsprüfung fehl, wird NICHTS gespeichert — die Kundin sieht eine klare
Fehlermeldung im Checkout und kann es erneut versuchen, statt dass VanVan eine "Bestellung"
ohne echtes Geld dahinter bekommt. ===================================================================== */
import { json } from "../../_shared/http.js";
import { paypalKonfiguriert, erfassePaypalZahlung } from "../../_shared/paypal.js";
import { erstelleBestellung } from "../../_shared/bestellung-erstellen.js";
// Kleine Toleranz für Rundungsdifferenzen (Cent-Rundung zwischen unserer Berechnung und PayPals
// eigener Darstellung) — alles darüber gilt als verdächtige Abweichung und wird abgelehnt.
const BETRAG_TOLERANZ_EURO = 0.02;
export async function onRequestPost(context) {
const { request, env } = context;
if (!paypalKonfiguriert(env)) {
return json(503, { ok: false, error: "PayPal ist auf dieser Seite noch nicht eingerichtet." });
}
let body;
try {
body = await request.json();
} catch {
return json(400, { ok: false, error: "Ungültige Anfrage." });
}
const paypalOrderId = typeof body.paypalOrderId === "string" ? body.paypalOrderId.trim() : "";
if (!paypalOrderId) {
return json(400, { ok: false, error: "Fehlende PayPal-Bestell-ID." });
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe <= 0) {
return json(400, { ok: false, error: "Ungültige Bestellsumme." });
}
// ── Schritt 1: Zahlung server-seitig bei PayPal einziehen + verifizieren. ──
const zahlung = await erfassePaypalZahlung(env, paypalOrderId);
if (!zahlung.ok) {
return json(402, { ok: false, error: `Zahlung konnte nicht bestätigt werden: ${zahlung.error}` });
}
// ── Schritt 2: Betrag gegenprüfen — verhindert, dass hier ein anderer (z.B. kleinerer) Betrag
// als der tatsächlich fällige "durchgeschmuggelt" wird. ──
if (Math.abs(zahlung.betragEuro - summe) > BETRAG_TOLERANZ_EURO) {
return json(402, {
ok: false,
error: `Der bei PayPal gezahlte Betrag (${zahlung.betragEuro.toFixed(2)} €) stimmt nicht mit der Bestellsumme (${summe.toFixed(2)} €) überein.`,
});
}
// ── Schritt 3: Erst JETZT, mit bestätigter echter Zahlung, die Bestellung anlegen. ──
const ergebnis = await erstelleBestellung(env, { ...body, zahlungsart: "paypal" }, {
status: "bezahlt",
paypalOrderId,
});
if (!ergebnis.ok) {
// Die Zahlung ist zu diesem Zeitpunkt bereits bei PayPal eingezogen, konnte aber nicht
// gespeichert werden (z.B. D1 kurzzeitig nicht erreichbar) — das ist ein seltener, aber
// ernster Fall: VanVan hat das Geld, aber keine Bestellung in der Verwaltung. Deshalb bekommt
// die Kundin eine Meldung, die sie NICHT zum erneuten Bezahlen auffordert, sondern zur
// Kontaktaufnahme, um Doppelzahlungen zu vermeiden.
return json(500, {
ok: false,
error: `Deine Zahlung wurde erfolgreich bei PayPal abgebucht (Beleg-ID: ${zahlung.captureId}), aber die Bestellung konnte nicht gespeichert werden. Bitte kontaktiere uns mit dieser Beleg-ID, damit wir das von Hand nachtragen — bitte NICHT erneut bezahlen.`,
});
}
return json(201, { ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
}
+42
View File
@@ -0,0 +1,42 @@
/* =====================================================================
POST /api/paypal/create-order — legt eine echte PayPal-Bestellung server-seitig an.
Wird vom Checkout aufgerufen, bevor der PayPal-Smart-Button überhaupt anzeigt, was zu bezahlen
ist (`createOrder`-Callback des PayPal-JS-SDK). Der Betrag kommt bewusst von UNS (aus dem im
Checkout berechneten Gesamtbetrag, den der Browser hier mitschickt) und wird 1:1 an PayPal
weitergereicht — die eigentliche Sicherheit entsteht erst beim Erfassen der Zahlung (siehe
capture-order.js), wo der TATSÄCHLICH bei PayPal eingezogene Betrag serverseitig geprüft wird.
===================================================================== */
import { json } from "../../_shared/http.js";
import { paypalKonfiguriert, erstellePaypalBestellung } from "../../_shared/paypal.js";
export async function onRequestPost(context) {
const { request, env } = context;
if (!paypalKonfiguriert(env)) {
return json(503, {
ok: false,
error: "PayPal ist auf dieser Seite noch nicht eingerichtet (fehlende Zugangsdaten). Bitte eine andere Zahlungsart wählen.",
});
}
let body;
try {
body = await request.json();
} catch {
return json(400, { ok: false, error: "Ungültige Anfrage." });
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe <= 0) {
return json(400, { ok: false, error: "Ungültiger Betrag." });
}
try {
const paypalOrderId = await erstellePaypalBestellung(env, summe);
return json(200, { ok: true, id: paypalOrderId });
} catch (err) {
return json(502, { ok: false, error: err instanceof Error ? err.message : "PayPal-Bestellung fehlgeschlagen." });
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
import { getGateRole, unauthorizedJson } from "../../../_shared/auth.js";
import { json } from "../../../_shared/http.js";
const ERLAUBTE_STATUS = ["bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
const ERLAUBTE_STATUS = ["zahlungOffen", "bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
export async function onRequestPatch(context) {
const { request, env, params } = context;