WebOrbiton
v1.0.0.1

StocketBase

564 lines · 22.3 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/site-front.php';
  8. ​
  9. header('Content-Type: application/json');
  10. ​
  11. const WEBHOOK_TOLERANCE_SECONDS = 300;
  12. ​
  13. $settings = SiteFront::settings();
  14. $rawBody = (string) file_get_contents('php://input');
  15. ​
  16. $stripeSignature = (string) ($_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '');
  17. $polarSignature = (string) ($_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');
  18. ​
  19. if ($stripeSignature !== '') {
  20. $provider = 'stripe';
  21. $webhookSecret = (string) $settings['payment_webhook_secret'];
  22. } elseif ($polarSignature !== '') {
  23. $provider = 'polar';
  24. $webhookSecret = (string) ($settings['payment_polar_webhook_secret'] !== '' ? $settings['payment_polar_webhook_secret'] : $settings['payment_webhook_secret']);
  25. } else {
  26. webhookRespond(400, ['error' => 'Missing signature header.']);
  27. }
  28. ​
  29. if ($webhookSecret === '') {
  30. webhookRespond(503, ['error' => 'Webhook secret not configured.']);
  31. }
  32. ​
  33. if ($provider === 'stripe') {
  34. if (!verifyStripeSignature($rawBody, $stripeSignature, $webhookSecret)) {
  35. webhookRespond(400, ['error' => 'Invalid signature.']);
  36. }
  37. } else {
  38. $polarWebhookId = (string) ($_SERVER['HTTP_WEBHOOK_ID'] ?? '');
  39. $polarTimestamp = (string) ($_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '');
  40. if (!verifyStandardWebhookSignature($rawBody, $polarWebhookId, $polarTimestamp, $polarSignature, $webhookSecret)) {
  41. webhookRespond(400, ['error' => 'Invalid signature.']);
  42. }
  43. }
  44. ​
  45. $payload = json_decode($rawBody, true);
  46. if (!is_array($payload)) {
  47. webhookRespond(400, ['error' => 'Invalid JSON payload.']);
  48. }
  49. ​
  50. $eventId = $provider === 'polar' ? (string) ($_SERVER['HTTP_WEBHOOK_ID'] ?? '') : (string) ($payload['id'] ?? '');
  51. $eventType = (string) ($payload['type'] ?? ($payload['event'] ?? ''));
  52. ​
  53. if ($eventId === '' || $eventType === '') {
  54. webhookRespond(400, ['error' => 'Missing event id or type.']);
  55. }
  56. ​
  57. $usersDb = Database::users();
  58. ​
  59. $existingStatement = $usersDb->prepare(
  60. 'SELECT id, processing_status FROM payment_webhook_events WHERE provider = :provider AND event_id = :event_id LIMIT 1'
  61. );
  62. $existingStatement->execute(['provider' => $provider, 'event_id' => $eventId]);
  63. $existingEvent = $existingStatement->fetch();
  64. ​
  65. if ($existingEvent && in_array($existingEvent['processing_status'], ['processed', 'ignored'], true)) {
  66. webhookRespond(200, ['status' => 'already_processed']);
  67. }
  68. ​
  69. if ($existingEvent) {
  70. $webhookRowId = (int) $existingEvent['id'];
  71. $usersDb->prepare("UPDATE payment_webhook_events SET processing_status = 'received', processing_error = NULL WHERE id = :id")
  72. ->execute(['id' => $webhookRowId]);
  73. } else {
  74. try {
  75. $usersDb->prepare(
  76. 'INSERT INTO payment_webhook_events (provider, event_id, event_type, payload, processing_status) VALUES (:provider, :event_id, :event_type, :payload, :status)'
  77. )->execute([
  78. 'provider' => $provider,
  79. 'event_id' => $eventId,
  80. 'event_type' => $eventType,
  81. 'payload' => $rawBody,
  82. 'status' => 'received',
  83. ]);
  84. } catch (PDOException $exception) {
  85. webhookRespond(200, ['status' => 'already_processing']);
  86. }
  87. $webhookRowId = (int) $usersDb->lastInsertId();
  88. }
  89. ​
  90. try {
  91. processWebhookEvent($usersDb, $provider, $eventType, $payload);
  92. ​
  93. $usersDb->prepare("UPDATE payment_webhook_events SET processing_status = 'processed', processed_at = NOW() WHERE id = :id")
  94. ->execute(['id' => $webhookRowId]);
  95. ​
  96. webhookRespond(200, ['status' => 'processed']);
  97. } catch (Throwable $exception) {
  98. error_log('StocketBase webhook: ' . $exception->getMessage());
  99. $usersDb->prepare("UPDATE payment_webhook_events SET processing_status = 'failed', processing_error = :error WHERE id = :id")
  100. ->execute(['id' => $webhookRowId, 'error' => substr($exception->getMessage(), 0, 500)]);
  101. ​
  102. webhookRespond(500, ['error' => 'Processing failed.']);
  103. }
  104. ​
  105. function webhookRespond(int $status, array $body): never
  106. {
  107. http_response_code($status);
  108. echo json_encode($body);
  109. exit;
  110. }
  111. ​
  112. function verifyStripeSignature(string $payload, string $signatureHeader, string $secret): bool
  113. {
  114. $timestamp = '';
  115. $signatures = [];
  116. foreach (explode(',', $signatureHeader) as $piece) {
  117. $pair = explode('=', trim($piece), 2);
  118. if (count($pair) !== 2) {
  119. continue;
  120. }
  121. if ($pair[0] === 't') {
  122. $timestamp = $pair[1];
  123. } elseif ($pair[0] === 'v1') {
  124. $signatures[] = $pair[1];
  125. }
  126. }
  127. ​
  128. if ($timestamp === '' || !ctype_digit($timestamp) || empty($signatures)) {
  129. return false;
  130. }
  131. ​
  132. if (abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
  133. return false;
  134. }
  135. ​
  136. $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
  137. foreach ($signatures as $signature) {
  138. if (hash_equals($expected, $signature)) {
  139. return true;
  140. }
  141. }
  142. ​
  143. return false;
  144. }
  145. ​
  146. function verifyStandardWebhookSignature(string $payload, string $webhookId, string $timestamp, string $signatureHeader, string $secret): bool
  147. {
  148. if ($webhookId === '' || $timestamp === '' || !ctype_digit($timestamp)) {
  149. return false;
  150. }
  151. ​
  152. if (abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
  153. return false;
  154. }
  155. ​
  156. $key = str_starts_with($secret, 'whsec_') ? (string) base64_decode(substr($secret, 6), true) : $secret;
  157. if ($key === '') {
  158. return false;
  159. }
  160. ​
  161. $expected = base64_encode(hash_hmac('sha256', $webhookId . '.' . $timestamp . '.' . $payload, $key, true));
  162. ​
  163. foreach (preg_split('/\s+/', trim($signatureHeader)) ?: [] as $entry) {
  164. $parts = explode(',', $entry, 2);
  165. if (count($parts) === 2 && $parts[0] === 'v1' && hash_equals($expected, $parts[1])) {
  166. return true;
  167. }
  168. }
  169. ​
  170. return false;
  171. }
  172. ​
  173. function processWebhookEvent(PDO $usersDb, string $provider, string $eventType, array $payload): void
  174. {
  175. $data = $payload['data']['object'] ?? ($payload['data'] ?? $payload);
  176. if (!is_array($data)) {
  177. return;
  178. }
  179. ​
  180. $normalizedType = strtolower($eventType);
  181. $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
  182. $orderId = isset($metadata['order_id']) ? (int) $metadata['order_id'] : 0;
  183. ​
  184. if ($orderId > 0) {
  185. $isStripePaid = $provider === 'stripe'
  186. && (($normalizedType === 'checkout.session.completed' && ($data['payment_status'] ?? '') === 'paid')
  187. || $normalizedType === 'checkout.session.async_payment_succeeded');
  188. $isPolarPaid = $provider === 'polar' && $normalizedType === 'order.paid';
  189. ​
  190. if ($isStripePaid || $isPolarPaid) {
  191. processOrderPaymentEvent($usersDb, $provider, $data, $orderId);
  192. return;
  193. }
  194. ​
  195. if ($provider === 'stripe' && in_array($normalizedType, ['checkout.session.expired', 'checkout.session.async_payment_failed'], true)) {
  196. markOrderFailed($usersDb, $orderId, $normalizedType === 'checkout.session.expired' ? 'Checkout session expired' : 'Payment failed');
  197. return;
  198. }
  199. }
  200. ​
  201. if ($provider === 'stripe' && $normalizedType === 'checkout.session.completed' && ($data['mode'] ?? '') === 'subscription') {
  202. processStripeSubscriptionCheckout($usersDb, $data);
  203. return;
  204. }
  205. ​
  206. if ($provider === 'stripe' && ($metadata['checkout_kind'] ?? '') === 'one_time'
  207. && (($normalizedType === 'checkout.session.completed' && ($data['payment_status'] ?? '') === 'paid') || $normalizedType === 'checkout.session.async_payment_succeeded')) {
  208. $productId = (int) ($metadata['product_id'] ?? 0);
  209. $customer = $data['customer'] ?? '';
  210. upsertSubscription($usersDb, 'stripe', (string) ($data['id'] ?? ''), findUserAccountId($usersDb, $data), [
  211. 'status' => 'active',
  212. 'provider_customer_id' => is_array($customer) ? (string) ($customer['id'] ?? '') : ((string) $customer !== '' ? (string) $customer : null),
  213. 'product_id' => $productId > 0 ? $productId : null,
  214. 'plan_code' => 'lifetime',
  215. ]);
  216. return;
  217. }
  218. ​
  219. if (str_contains($normalizedType, 'subscription')) {
  220. processSubscriptionEvent($usersDb, $provider, $normalizedType, $data);
  221. return;
  222. }
  223. ​
  224. if (($orderId === 0) && SiteFront::settings()['subscription_interval'] === 'lifetime') {
  225. processLifetimeEvent($usersDb, $provider, $normalizedType, $data);
  226. }
  227. }
  228. ​
  229. function markOrderFailed(PDO $usersDb, int $orderId, string $note): void
  230. {
  231. $update = $usersDb->prepare("UPDATE orders SET status = 'failed' WHERE id = :id AND status = 'pending'");
  232. $update->execute(['id' => $orderId]);
  233. if ($update->rowCount() > 0) {
  234. $usersDb->prepare("INSERT INTO order_status_history (order_id, status, note) VALUES (:order_id, 'failed', :note)")
  235. ->execute(['order_id' => $orderId, 'note' => $note]);
  236. }
  237. }
  238. ​
  239. function processOrderPaymentEvent(PDO $usersDb, string $provider, array $data, int $orderId): void
  240. {
  241. $orderStatement = $usersDb->prepare('SELECT * FROM orders WHERE id = :id LIMIT 1');
  242. $orderStatement->execute(['id' => $orderId]);
  243. $order = $orderStatement->fetch();
  244. ​
  245. if (!$order || !in_array($order['status'], ['pending', 'failed'], true)) {
  246. return;
  247. }
  248. ​
  249. if ($provider === 'stripe') {
  250. $paidAmount = isset($data['amount_total']) ? (int) $data['amount_total'] : null;
  251. $paidCurrency = strtoupper((string) ($data['currency'] ?? ''));
  252. if ($paidAmount === null || $paidAmount !== (int) $order['total_cents'] || ($paidCurrency !== '' && $paidCurrency !== strtoupper((string) $order['currency']))) {
  253. $usersDb->prepare("INSERT INTO order_status_history (order_id, status, note) VALUES (:order_id, :status, :note)")
  254. ->execute([
  255. 'order_id' => $orderId,
  256. 'status' => $order['status'],
  257. 'note' => 'Payment amount mismatch: received ' . (string) $paidAmount . ' ' . $paidCurrency . ', expected ' . (int) $order['total_cents'] . ' ' . $order['currency'],
  258. ]);
  259. throw new RuntimeException('Payment amount mismatch for order ' . $orderId);
  260. }
  261. }
  262. ​
  263. $providerCheckoutId = (string) ($data['id'] ?? '');
  264. $paymentIntent = $data['payment_intent'] ?? null;
  265. $providerPaymentId = is_array($paymentIntent) ? (string) ($paymentIntent['id'] ?? '') : (string) ($paymentIntent ?? ($data['id'] ?? ''));
  266. $customer = $data['customer'] ?? ($data['customer_id'] ?? '');
  267. $providerCustomerId = is_array($customer) ? (string) ($customer['id'] ?? '') : (string) $customer;
  268. ​
  269. $updateOrder = $usersDb->prepare(
  270. "UPDATE orders SET status = 'paid', provider = :provider, provider_checkout_id = :checkout_id, provider_payment_id = :payment_id, provider_customer_id = :customer_id
  271. WHERE id = :id AND status IN ('pending', 'failed')"
  272. );
  273. $updateOrder->execute([
  274. 'provider' => $provider,
  275. 'checkout_id' => $providerCheckoutId !== '' ? $providerCheckoutId : null,
  276. 'payment_id' => $providerPaymentId !== '' ? $providerPaymentId : null,
  277. 'customer_id' => $providerCustomerId !== '' ? $providerCustomerId : null,
  278. 'id' => $orderId,
  279. ]);
  280. ​
  281. if ($updateOrder->rowCount() === 0) {
  282. return;
  283. }
  284. ​
  285. $usersDb->prepare(
  286. "INSERT INTO order_status_history (order_id, status, note) VALUES (:order_id, 'paid', 'Payment confirmed via webhook')"
  287. )->execute(['order_id' => $orderId]);
  288. ​
  289. $itemsStatement = $usersDb->prepare('SELECT * FROM order_items WHERE order_id = :id');
  290. $itemsStatement->execute(['id' => $orderId]);
  291. $items = $itemsStatement->fetchAll();
  292. ​
  293. $siteDb = Database::site();
  294. ​
  295. foreach ($items as $item) {
  296. $productId = (int) $item['product_id'];
  297. $variantId = $item['variant_id'] !== null ? (int) $item['variant_id'] : null;
  298. $quantity = (int) $item['quantity'];
  299. ​
  300. $productStatement = $siteDb->prepare('SELECT track_inventory FROM products WHERE id = :id LIMIT 1');
  301. $productStatement->execute(['id' => $productId]);
  302. $product = $productStatement->fetch();
  303. ​
  304. if ($product && (int) $product['track_inventory'] === 1) {
  305. $variantUpdated = 0;
  306. if ($variantId !== null) {
  307. $variantUpdate = $siteDb->prepare(
  308. 'UPDATE product_variants SET stock_quantity = GREATEST(0, stock_quantity - :qty) WHERE id = :id AND stock_quantity IS NOT NULL'
  309. );
  310. $variantUpdate->execute(['qty' => $quantity, 'id' => $variantId]);
  311. $variantUpdated = $variantUpdate->rowCount();
  312. }
  313. if ($variantUpdated === 0) {
  314. $siteDb->prepare(
  315. 'UPDATE products SET stock_quantity = GREATEST(0, stock_quantity - :qty) WHERE id = :id AND stock_quantity IS NOT NULL'
  316. )->execute(['qty' => $quantity, 'id' => $productId]);
  317. }
  318. }
  319. ​
  320. $siteDb->prepare('UPDATE products SET sales_count = sales_count + :qty WHERE id = :id')
  321. ->execute(['qty' => $quantity, 'id' => $productId]);
  322. }
  323. ​
  324. if (!empty($order['discount_code'])) {
  325. $siteDb->prepare(
  326. 'UPDATE discounts SET times_redeemed = times_redeemed + 1 WHERE UPPER(code) = UPPER(:code)'
  327. )->execute(['code' => $order['discount_code']]);
  328. }
  329. ​
  330. $downloadLinks = [];
  331. try {
  332. require_once __DIR__ . '/includes/digital-files.php';
  333. $downloadLinks = DigitalFiles::grantsForOrder($usersDb, $order, $items);
  334. } catch (Throwable $grantException) {
  335. error_log('StocketBase webhook: could not create download access — ' . $grantException->getMessage());
  336. }
  337. ​
  338. try {
  339. sendOrderConfirmationEmail($usersDb, $order, $items, $downloadLinks);
  340. } catch (Throwable $mailException) {
  341. error_log('StocketBase webhook: order confirmation email failed — ' . $mailException->getMessage());
  342. }
  343. }
  344. ​
  345. function sendOrderConfirmationEmail(PDO $usersDb, array $order, array $items, array $downloadLinks = []): void
  346. {
  347. if (!is_file(__DIR__ . '/includes/mailer.php') || !is_file(__DIR__ . '/includes/mail-templates.php')) {
  348. return;
  349. }
  350. ​
  351. require_once __DIR__ . '/includes/mailer.php';
  352. require_once __DIR__ . '/includes/mail-templates.php';
  353. ​
  354. if (!class_exists('Mailer') || !class_exists('MailTemplates')) {
  355. return;
  356. }
  357. ​
  358. $recipientEmail = (string) ($order['guest_email'] ?? '');
  359. if ($recipientEmail === '' && $order['user_account_id'] !== null) {
  360. $accountStatement = $usersDb->prepare('SELECT email FROM user_accounts WHERE id = :id LIMIT 1');
  361. $accountStatement->execute(['id' => $order['user_account_id']]);
  362. $recipientEmail = (string) $accountStatement->fetchColumn();
  363. }
  364. ​
  365. if ($recipientEmail !== '') {
  366. $email = MailTemplates::orderConfirmationEmail($order, $items, $downloadLinks);
  367. Mailer::send($recipientEmail, $email['subject'], $email['html']);
  368. }
  369. }
  370. ​
  371. function findUserAccountId(PDO $usersDb, array $data): ?int
  372. {
  373. $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
  374. $metadataUserId = (int) ($metadata['user_account_id'] ?? 0);
  375. if ($metadataUserId > 0) {
  376. $statement = $usersDb->prepare('SELECT id FROM user_accounts WHERE id = :id LIMIT 1');
  377. $statement->execute(['id' => $metadataUserId]);
  378. if ($statement->fetch()) {
  379. return $metadataUserId;
  380. }
  381. }
  382. ​
  383. $email = strtolower(trim((string) (
  384. $data['customer_email'] ??
  385. $data['email'] ??
  386. $data['customer_details']['email'] ??
  387. (is_array($data['customer'] ?? null) ? ($data['customer']['email'] ?? null) : null) ??
  388. ''
  389. )));
  390. ​
  391. if ($email === '') {
  392. return null;
  393. }
  394. ​
  395. $statement = $usersDb->prepare('SELECT id FROM user_accounts WHERE LOWER(email) = :email LIMIT 1');
  396. $statement->execute(['email' => $email]);
  397. $account = $statement->fetch();
  398. ​
  399. return $account ? (int) $account['id'] : null;
  400. }
  401. ​
  402. function mapSubscriptionStatus(string $eventType, array $data): ?string
  403. {
  404. if (str_contains($eventType, 'deleted') || str_contains($eventType, 'revoked') || str_contains($eventType, 'canceled') || str_contains($eventType, 'cancelled')) {
  405. return 'canceled';
  406. }
  407. ​
  408. return match (strtolower((string) ($data['status'] ?? ''))) {
  409. 'active' => 'active',
  410. 'trialing' => 'trialing',
  411. 'past_due', 'unpaid', 'incomplete' => 'past_due',
  412. 'canceled', 'cancelled', 'paused' => 'canceled',
  413. 'incomplete_expired', 'expired' => 'expired',
  414. default => null,
  415. };
  416. }
  417. ​
  418. function subscriptionPeriodEnd(array $data): ?string
  419. {
  420. $raw = $data['current_period_end'] ?? ($data['items']['data'][0]['current_period_end'] ?? null);
  421. if (empty($raw)) {
  422. return null;
  423. }
  424. ​
  425. $timestamp = is_numeric($raw) ? (int) $raw : strtotime((string) $raw);
  426. ​
  427. return $timestamp ? date('Y-m-d H:i:s', $timestamp) : null;
  428. }
  429. ​
  430. function upsertSubscription(PDO $usersDb, string $provider, string $subscriptionId, ?int $userAccountId, array $fields): void
  431. {
  432. $existingStatement = $usersDb->prepare(
  433. 'SELECT id FROM subscriptions WHERE provider = :provider AND provider_subscription_id = :sub_id LIMIT 1'
  434. );
  435. $existingStatement->execute(['provider' => $provider, 'sub_id' => $subscriptionId]);
  436. $existing = $existingStatement->fetch();
  437. ​
  438. if ($existing) {
  439. $sets = [];
  440. $params = ['id' => $existing['id']];
  441. foreach (['status', 'current_period_end', 'provider_customer_id', 'product_id', 'plan_code'] as $column) {
  442. if (array_key_exists($column, $fields) && $fields[$column] !== null) {
  443. $sets[] = $column . ' = :' . $column;
  444. $params[$column] = $fields[$column];
  445. }
  446. }
  447. if (($fields['status'] ?? null) === 'canceled') {
  448. $sets[] = 'canceled_at = COALESCE(canceled_at, NOW())';
  449. }
  450. if (!empty($sets)) {
  451. $usersDb->prepare('UPDATE subscriptions SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params);
  452. }
  453. return;
  454. }
  455. ​
  456. if ($userAccountId === null) {
  457. return;
  458. }
  459. ​
  460. $usersDb->prepare(
  461. 'INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, product_id, status, current_period_end)
  462. VALUES (:user_id, :provider, :customer_id, :sub_id, :plan_code, :product_id, :status, :period_end)'
  463. )->execute([
  464. 'user_id' => $userAccountId,
  465. 'provider' => $provider,
  466. 'customer_id' => $fields['provider_customer_id'] ?? null,
  467. 'sub_id' => $subscriptionId,
  468. 'plan_code' => $fields['plan_code'] ?? null,
  469. 'product_id' => $fields['product_id'] ?? null,
  470. 'status' => $fields['status'] ?? 'active',
  471. 'period_end' => $fields['current_period_end'] ?? null,
  472. ]);
  473. }
  474. ​
  475. function processStripeSubscriptionCheckout(PDO $usersDb, array $data): void
  476. {
  477. $subscriptionId = is_array($data['subscription'] ?? null) ? (string) ($data['subscription']['id'] ?? '') : (string) ($data['subscription'] ?? '');
  478. if ($subscriptionId === '') {
  479. return;
  480. }
  481. ​
  482. $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
  483. $productId = (int) ($metadata['product_id'] ?? 0);
  484. $customer = $data['customer'] ?? '';
  485. ​
  486. upsertSubscription($usersDb, 'stripe', $subscriptionId, findUserAccountId($usersDb, $data), [
  487. 'status' => 'active',
  488. 'provider_customer_id' => is_array($customer) ? (string) ($customer['id'] ?? '') : ((string) $customer !== '' ? (string) $customer : null),
  489. 'product_id' => $productId > 0 ? $productId : null,
  490. 'plan_code' => $productId > 0 ? (string) $productId : null,
  491. ]);
  492. }
  493. ​
  494. function processSubscriptionEvent(PDO $usersDb, string $provider, string $eventType, array $data): void
  495. {
  496. $subscriptionId = (string) ($data['id'] ?? '');
  497. if ($subscriptionId === '') {
  498. return;
  499. }
  500. ​
  501. $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
  502. $productId = (int) ($metadata['product_id'] ?? 0);
  503. $customer = $data['customer'] ?? ($data['customer_id'] ?? '');
  504. $customerId = is_array($customer) ? (string) ($customer['id'] ?? '') : (string) $customer;
  505. $planCode = (string) ($data['plan']['id'] ?? ($data['product_id'] ?? ''));
  506. ​
  507. upsertSubscription($usersDb, $provider, $subscriptionId, findUserAccountId($usersDb, $data), [
  508. 'status' => mapSubscriptionStatus($eventType, $data),
  509. 'current_period_end' => subscriptionPeriodEnd($data),
  510. 'provider_customer_id' => $customerId !== '' ? $customerId : null,
  511. 'product_id' => $productId > 0 ? $productId : null,
  512. 'plan_code' => $planCode !== '' ? $planCode : null,
  513. ]);
  514. }
  515. ​
  516. function processLifetimeEvent(PDO $usersDb, string $provider, string $eventType, array $data): void
  517. {
  518. $paymentId = (string) ($data['id'] ?? '');
  519. if ($paymentId === '') {
  520. return;
  521. }
  522. ​
  523. if ($provider === 'polar' && $eventType === 'order.refunded') {
  524. $usersDb->prepare(
  525. "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE provider = :provider AND provider_subscription_id = :id AND plan_code = 'lifetime'"
  526. )->execute(['provider' => $provider, 'id' => $paymentId]);
  527. return;
  528. }
  529. ​
  530. $isPaid = ($provider === 'stripe' && $eventType === 'checkout.session.completed'
  531. && ($data['payment_status'] ?? '') === 'paid' && ($data['mode'] ?? 'payment') === 'payment')
  532. || ($provider === 'polar' && $eventType === 'order.paid');
  533. ​
  534. if (!$isPaid) {
  535. return;
  536. }
  537. ​
  538. $userAccountId = findUserAccountId($usersDb, $data);
  539. if ($userAccountId === null) {
  540. return;
  541. }
  542. ​
  543. $existing = $usersDb->prepare(
  544. 'SELECT id FROM subscriptions WHERE provider = :provider AND provider_subscription_id = :id LIMIT 1'
  545. );
  546. $existing->execute(['provider' => $provider, 'id' => $paymentId]);
  547. if ($existing->fetch()) {
  548. return;
  549. }
  550. ​
  551. $customer = $data['customer'] ?? ($data['customer_id'] ?? '');
  552. $customerId = is_array($customer) ? (string) ($customer['id'] ?? '') : (string) $customer;
  553. ​
  554. $usersDb->prepare(
  555. "INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, status, current_period_end)
  556. VALUES (:user_id, :provider, :customer_id, :payment_id, 'lifetime', 'active', NULL)"
  557. )->execute([
  558. 'user_id' => $userAccountId,
  559. 'provider' => $provider,
  560. 'customer_id' => $customerId !== '' ? $customerId : null,
  561. 'payment_id' => $paymentId,
  562. ]);
  563. }
  564. ​