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]>
104 lines
5.5 KiB
JavaScript
104 lines
5.5 KiB
JavaScript
/* =====================================================================
|
|
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) };
|
|
}
|
|
}
|