Files
2026-06-08 15:44:15 -04:00

141 lines
6.7 KiB
PHP

<?php
function stripeRequest(string $method, string $path, array $fields = []): array {
$secret = trim((string)getConfigVal('stripe.secret_key', ''));
if ($secret === '') throw new RuntimeException('Stripe is not configured');
if (!function_exists('curl_init')) throw new RuntimeException('PHP cURL extension is required');
$url = 'https://api.stripe.com/v1/' . ltrim($path, '/');
if ($method === 'GET' && $fields) $url .= '?' . http_build_query($fields);
$curl = curl_init($url);
$options = [
CURLOPT_USERPWD => $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();
}