/* ===================================================================== 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) }; } }