147 lines
5.6 KiB
JavaScript
147 lines
5.6 KiB
JavaScript
/* =====================================================================
|
|
lib/paypal.js — PayPal Subscriptions API Anbindung
|
|
(Dogfather_VanVan_Supporter_Abo.odt, Abschnitt 7/8)
|
|
|
|
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
|
|
Diese Datei ist vollständig fertig implementiert (OAuth2, Subscription
|
|
erstellen, Subscription abfragen, Webhook-Signatur prüfen), kann aber
|
|
erst wirklich Geld verarbeiten, sobald folgende Secrets gesetzt sind
|
|
(per `wrangler secret put NAME`, NIEMALS im Code/Repo):
|
|
PAYPAL_CLIENT_ID
|
|
PAYPAL_CLIENT_SECRET
|
|
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto, Abschnitt 7)
|
|
PAYPAL_WEBHOOK_ID
|
|
Und der Var (in wrangler.toml, unkritisch, kein Secret):
|
|
PAYPAL_ENV = "sandbox" | "live"
|
|
|
|
Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal-
|
|
Business-Konto + Developer-App sind an seine eigene Identität/Bank
|
|
gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines
|
|
Absturzes, solange sie fehlen.
|
|
===================================================================== */
|
|
|
|
export class PayPalNotConfiguredError extends Error {
|
|
constructor() {
|
|
super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen).");
|
|
this.name = "PayPalNotConfiguredError";
|
|
this.code = "PAYPAL_NOT_CONFIGURED";
|
|
}
|
|
}
|
|
|
|
function baseUrl(env) {
|
|
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
|
}
|
|
|
|
function assertConfigured(env) {
|
|
if (!env.PAYPAL_CLIENT_ID || !env.PAYPAL_CLIENT_SECRET || !env.PAYPAL_PLAN_ID) {
|
|
throw new PayPalNotConfiguredError();
|
|
}
|
|
}
|
|
|
|
let cachedToken = null; // { value, expiresAt } — pro Worker-Instanz, spart Requests
|
|
|
|
async function getAccessToken(env) {
|
|
assertConfigured(env);
|
|
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
|
|
|
|
const res = await fetch(`${baseUrl(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) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`);
|
|
const data = await res.json();
|
|
cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 };
|
|
return cachedToken.value;
|
|
}
|
|
|
|
async function paypalFetch(env, path, options = {}) {
|
|
const token = await getAccessToken(env);
|
|
const res = await fetch(`${baseUrl(env)}${path}`, {
|
|
...options,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
...(options.headers || {}),
|
|
},
|
|
});
|
|
const text = await res.text();
|
|
const data = text ? JSON.parse(text) : null;
|
|
if (!res.ok) {
|
|
const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`);
|
|
err.paypalStatus = res.status;
|
|
err.paypalBody = data;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Erstellt ein PayPal-Abonnement für einen Supporter und liefert den
|
|
* "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um
|
|
* das Abo direkt bei PayPal zu bestätigen (Abschnitt 6, Schritt 6-7).
|
|
* custom_id trägt unsere interne supporter_id, damit der Webhook später
|
|
* weiß, wem die Zahlung gehört.
|
|
*/
|
|
export async function createSubscription(env, { supporterId, returnUrl, cancelUrl }) {
|
|
const data = await paypalFetch(env, "/v1/billing/subscriptions", {
|
|
method: "POST",
|
|
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
|
|
body: JSON.stringify({
|
|
plan_id: env.PAYPAL_PLAN_ID,
|
|
custom_id: supporterId,
|
|
application_context: {
|
|
brand_name: "Dogfather",
|
|
locale: "de-DE",
|
|
shipping_preference: "NO_SHIPPING",
|
|
user_action: "SUBSCRIBE_NOW",
|
|
return_url: returnUrl,
|
|
cancel_url: cancelUrl,
|
|
},
|
|
}),
|
|
});
|
|
const approveLink = (data.links || []).find((l) => l.rel === "approve");
|
|
return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data };
|
|
}
|
|
|
|
export async function getSubscription(env, paypalSubscriptionId) {
|
|
return paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
|
|
}
|
|
|
|
/** Kündigung (Abschnitt 22: "verständlich und leicht kündbar"). Zugang bleibt bis Periodenende bestehen. */
|
|
export async function cancelSubscription(env, paypalSubscriptionId, reason) {
|
|
await paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle
|
|
* Verify-Signature-Funktion (Abschnitt 8, "sichere PayPal-Webhook-Prüfung"
|
|
* aus den Sicherheitsanforderungen, Abschnitt 29).
|
|
*/
|
|
export async function verifyWebhookSignature(env, request, rawBody) {
|
|
assertConfigured(env);
|
|
if (!env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
|
|
|
|
const h = request.headers;
|
|
const verification = await paypalFetch(env, "/v1/notifications/verify-webhook-signature", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
auth_algo: h.get("paypal-auth-algo"),
|
|
cert_url: h.get("paypal-cert-url"),
|
|
transmission_id: h.get("paypal-transmission-id"),
|
|
transmission_sig: h.get("paypal-transmission-sig"),
|
|
transmission_time: h.get("paypal-transmission-time"),
|
|
webhook_id: env.PAYPAL_WEBHOOK_ID,
|
|
webhook_event: JSON.parse(rawBody),
|
|
}),
|
|
});
|
|
return verification.verification_status === "SUCCESS";
|
|
}
|