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