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) };
}
}