$secret . ':', CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 25, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], ]; if ($method !== 'GET' && $fields) $options[CURLOPT_POSTFIELDS] = http_build_query($fields); curl_setopt_array($curl, $options); $body = curl_exec($curl); $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $error = curl_error($curl); curl_close($curl); $decoded = json_decode((string)$body, true); if ($body === false || $status < 200 || $status >= 300) { $message = $decoded['error']['message'] ?? $error ?: ('Stripe HTTP ' . $status); throw new RuntimeException($message); } return is_array($decoded) ? $decoded : []; } function stripeReturnUrl(string $configKey, string $fallback): string { $configured = trim((string)getConfigVal($configKey, '')); return $configured !== '' ? $configured : $fallback; } function createStripeCheckout(array $user, array $plan): string { if (empty($user['email_verified_at']) || empty($user['email'])) { throw new RuntimeException('A verified email is required before checkout'); } if (empty($plan['stripe_price_id'])) throw new RuntimeException('This plan does not have a Stripe price ID'); $base = appBaseUrl(); $fields = [ 'mode' => 'subscription', 'line_items[0][price]' => $plan['stripe_price_id'], 'line_items[0][quantity]' => 1, 'success_url' => stripeReturnUrl('stripe.success_url', $base . '/index.html?billing=success'), 'cancel_url' => stripeReturnUrl('stripe.cancel_url', $base . '/index.html?billing=cancelled'), 'client_reference_id' => (string)$user['id'], 'metadata[user_id]' => (string)$user['id'], 'metadata[plan_id]' => (string)$plan['id'], 'subscription_data[metadata][user_id]' => (string)$user['id'], 'subscription_data[metadata][plan_id]' => (string)$plan['id'], 'allow_promotion_codes' => 'true', ]; if (!empty($user['stripe_customer_id'])) $fields['customer'] = $user['stripe_customer_id']; else $fields['customer_email'] = $user['email']; $session = stripeRequest('POST', 'checkout/sessions', $fields); if (empty($session['url'])) throw new RuntimeException('Stripe did not return a checkout URL'); return $session['url']; } function createStripePortal(array $user): string { if (empty($user['stripe_customer_id'])) throw new RuntimeException('No Stripe customer is connected'); $session = stripeRequest('POST', 'billing_portal/sessions', [ 'customer' => $user['stripe_customer_id'], 'return_url' => stripeReturnUrl('stripe.portal_return_url', appBaseUrl() . '/index.html?view=account'), ]); if (empty($session['url'])) throw new RuntimeException('Stripe did not return a portal URL'); return $session['url']; } function verifyStripeSignature(string $payload, string $header): bool { $secret = trim((string)getConfigVal('stripe.webhook_secret', '')); if ($secret === '' || $header === '') return false; $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') $timestamp = $value; if ($key === 'v1') $signatures[] = $value; } if (!$timestamp || abs(time() - (int)$timestamp) > 300) return false; $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); foreach ($signatures as $signature) if (hash_equals($expected, $signature)) return true; return false; } function planIdFromStripeObject(array $object): ?int { $metadataPlan = (int)($object['metadata']['plan_id'] ?? 0); if ($metadataPlan > 0 && planById($metadataPlan)) return $metadataPlan; $priceId = $object['items']['data'][0]['price']['id'] ?? ''; if ($priceId !== '') { $stmt = getDB()->prepare("SELECT id FROM plans WHERE stripe_price_id=?"); $stmt->execute([$priceId]); $id = (int)$stmt->fetchColumn(); if ($id > 0) return $id; } return null; } function syncSubscriptionFromStripe(array $object): void { $db = getDB(); $subscriptionId = (string)($object['id'] ?? ''); $customerId = (string)($object['customer'] ?? ''); $userId = (int)($object['metadata']['user_id'] ?? 0); if ($userId < 1 && $subscriptionId !== '') { $stmt = $db->prepare("SELECT user_id FROM subscriptions WHERE stripe_subscription_id=?"); $stmt->execute([$subscriptionId]); $userId = (int)$stmt->fetchColumn(); } if ($userId < 1 && $customerId !== '') { $stmt = $db->prepare("SELECT id FROM users WHERE stripe_customer_id=?"); $stmt->execute([$customerId]); $userId = (int)$stmt->fetchColumn(); } if ($userId < 1) return; $status = (string)($object['status'] ?? 'active'); $paidStatuses = ['active', 'trialing', 'past_due']; $planId = planIdFromStripeObject($object); if (!in_array($status, $paidStatuses, true)) { $planId = (int)$db->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn(); } if (!$planId) return; $periodEnd = (int)($object['current_period_end'] ?? 0) ?: null; $cancel = !empty($object['cancel_at_period_end']) ? 1 : 0; $db->beginTransaction(); $db->prepare("INSERT INTO subscriptions (user_id,plan_id,stripe_customer_id,stripe_subscription_id,status,current_period_end,cancel_at_period_end,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id,stripe_customer_id=excluded.stripe_customer_id, stripe_subscription_id=excluded.stripe_subscription_id,status=excluded.status, current_period_end=excluded.current_period_end,cancel_at_period_end=excluded.cancel_at_period_end,updated_at=excluded.updated_at") ->execute([$userId, $planId, $customerId, $subscriptionId, $status, $periodEnd, $cancel, time()]); $db->prepare("UPDATE users SET plan_id=?,stripe_customer_id=?,stripe_subscription_id=?, subscription_status=?,subscription_period_end=?,cancel_at_period_end=? WHERE id=?") ->execute([$planId, $customerId, $subscriptionId, $status, $periodEnd, $cancel, $userId]); $db->commit(); }