WebOrbiton
v1.0.0.6

Publisium

396 lines · 13.5 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. const WEBHOOK_TOLERANCE_SECONDS = 300;
  10. ​
  11. header('Content-Type: application/json');
  12. ​
  13. $settings = SiteFront::settings();
  14. $provider = $settings['payment_provider'];
  15. $webhookSecret = $settings['payment_webhook_secret'];
  16. ​
  17. $rawBody = (string) file_get_contents('php://input');
  18. ​
  19. if ($webhookSecret === '') {
  20. http_response_code(503);
  21. echo json_encode(['error' => 'Webhook secret not configured.']);
  22. exit;
  23. }
  24. ​
  25. if ($provider === 'stripe') {
  26. $signatureHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
  27. if (!verifyStripeSignature($rawBody, $signatureHeader, $webhookSecret)) {
  28. http_response_code(400);
  29. echo json_encode(['error' => 'Invalid signature.']);
  30. exit;
  31. }
  32. } elseif ($provider === 'polar') {
  33. $polarEventId = (string) ($_SERVER['HTTP_WEBHOOK_ID'] ?? '');
  34. $polarTimestamp = (string) ($_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '');
  35. $signatureHeader = (string) ($_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');
  36. if (!verifyPolarSignature($rawBody, $polarEventId, $polarTimestamp, $signatureHeader, $webhookSecret)) {
  37. http_response_code(400);
  38. echo json_encode(['error' => 'Invalid signature.']);
  39. exit;
  40. }
  41. } else {
  42. http_response_code(400);
  43. echo json_encode(['error' => 'Unknown payment provider configured.']);
  44. exit;
  45. }
  46. ​
  47. $payload = json_decode($rawBody, true);
  48. if (!is_array($payload)) {
  49. http_response_code(400);
  50. echo json_encode(['error' => 'Invalid JSON payload.']);
  51. exit;
  52. }
  53. ​
  54. $eventId = $provider === 'polar' ? $polarEventId : (string) ($payload['id'] ?? '');
  55. $eventType = (string) ($payload['type'] ?? ($payload['event'] ?? ''));
  56. ​
  57. if ($eventId === '' || $eventType === '') {
  58. http_response_code(400);
  59. echo json_encode(['error' => 'Missing event id or type.']);
  60. exit;
  61. }
  62. ​
  63. $usersDb = Database::users();
  64. ​
  65. $existingStatement = $usersDb->prepare(
  66. 'SELECT id FROM payment_webhook_events WHERE provider = :provider AND event_id = :event_id LIMIT 1'
  67. );
  68. $existingStatement->execute(['provider' => $provider, 'event_id' => $eventId]);
  69. if ($existingStatement->fetch()) {
  70. echo json_encode(['status' => 'already_processed']);
  71. exit;
  72. }
  73. ​
  74. $insertEvent = $usersDb->prepare(
  75. 'INSERT INTO payment_webhook_events (provider, event_id, event_type, payload, processing_status) VALUES (:provider, :event_id, :event_type, :payload, :status)'
  76. );
  77. $insertEvent->execute([
  78. 'provider' => $provider,
  79. 'event_id' => $eventId,
  80. 'event_type' => $eventType,
  81. 'payload' => $rawBody,
  82. 'status' => 'received',
  83. ]);
  84. $webhookRowId = (int) $usersDb->lastInsertId();
  85. ​
  86. try {
  87. processWebhookEvent($usersDb, $provider, $eventType, $payload);
  88. ​
  89. $updateEvent = $usersDb->prepare(
  90. "UPDATE payment_webhook_events SET processing_status = 'processed', processed_at = NOW() WHERE id = :id"
  91. );
  92. $updateEvent->execute(['id' => $webhookRowId]);
  93. ​
  94. echo json_encode(['status' => 'processed']);
  95. } catch (Throwable $exception) {
  96. $updateEvent = $usersDb->prepare(
  97. "UPDATE payment_webhook_events SET processing_status = 'failed', processing_error = :error WHERE id = :id"
  98. );
  99. $updateEvent->execute(['id' => $webhookRowId, 'error' => substr($exception->getMessage(), 0, 500)]);
  100. ​
  101. http_response_code(500);
  102. echo json_encode(['error' => 'Processing failed.']);
  103. }
  104. ​
  105. function verifyStripeSignature(string $payload, string $signatureHeader, string $secret): bool
  106. {
  107. if ($signatureHeader === '') {
  108. return false;
  109. }
  110. ​
  111. $timestamp = '';
  112. $signatures = [];
  113. foreach (explode(',', $signatureHeader) as $piece) {
  114. $pair = explode('=', trim($piece), 2);
  115. if (count($pair) !== 2) {
  116. continue;
  117. }
  118. if ($pair[0] === 't') {
  119. $timestamp = $pair[1];
  120. } elseif ($pair[0] === 'v1') {
  121. $signatures[] = $pair[1];
  122. }
  123. }
  124. ​
  125. if (!ctype_digit($timestamp) || $signatures === [] || abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
  126. return false;
  127. }
  128. ​
  129. $expectedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
  130. foreach ($signatures as $signature) {
  131. if (hash_equals($expectedSignature, $signature)) {
  132. return true;
  133. }
  134. }
  135. ​
  136. return false;
  137. }
  138. ​
  139. function verifyPolarSignature(string $payload, string $eventId, string $timestamp, string $signatureHeader, string $secret): bool
  140. {
  141. if ($eventId === '' || $signatureHeader === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
  142. return false;
  143. }
  144. ​
  145. $key = $secret;
  146. if (str_starts_with($secret, 'whsec_')) {
  147. $decoded = base64_decode(substr($secret, 6), true);
  148. if ($decoded !== false) {
  149. $key = $decoded;
  150. }
  151. }
  152. ​
  153. $expectedSignature = base64_encode(hash_hmac('sha256', $eventId . '.' . $timestamp . '.' . $payload, $key, true));
  154. foreach (preg_split('/\s+/', trim($signatureHeader)) ?: [] as $entry) {
  155. [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
  156. if ($version === 'v1' && hash_equals($expectedSignature, $signature)) {
  157. return true;
  158. }
  159. }
  160. ​
  161. return false;
  162. }
  163. ​
  164. function processWebhookEvent(PDO $usersDb, string $provider, string $eventType, array $payload): void
  165. {
  166. $data = $payload['data']['object'] ?? ($payload['data'] ?? $payload);
  167. if (!is_array($data)) {
  168. return;
  169. }
  170. ​
  171. $normalizedType = strtolower($eventType);
  172. ​
  173. if ($provider === 'stripe' && $normalizedType === 'checkout.session.completed' && ($data['mode'] ?? '') === 'subscription') {
  174. processStripeSubscriptionCheckout($usersDb, $data);
  175. return;
  176. }
  177. ​
  178. if (str_starts_with($normalizedType, 'customer.subscription.') || str_starts_with($normalizedType, 'subscription.')) {
  179. processSubscriptionEvent($usersDb, $provider, $normalizedType, $data);
  180. return;
  181. }
  182. ​
  183. if (SiteFront::settings()['subscription_interval'] === 'lifetime') {
  184. processLifetimeEvent($usersDb, $provider, $normalizedType, $data);
  185. }
  186. }
  187. ​
  188. function providerObjectId(mixed $value): string
  189. {
  190. return is_array($value) ? (string) ($value['id'] ?? '') : (string) ($value ?? '');
  191. }
  192. ​
  193. function findUserAccountId(PDO $usersDb, array $data): ?int
  194. {
  195. $email = strtolower(trim((string) (
  196. $data['customer_email'] ??
  197. $data['email'] ??
  198. $data['customer_details']['email'] ??
  199. (is_array($data['customer'] ?? null) ? ($data['customer']['email'] ?? null) : null) ??
  200. ''
  201. )));
  202. ​
  203. if ($email === '') {
  204. return null;
  205. }
  206. ​
  207. $statement = $usersDb->prepare('SELECT id FROM user_accounts WHERE email = :email LIMIT 1');
  208. $statement->execute(['email' => $email]);
  209. $account = $statement->fetch();
  210. ​
  211. return $account ? (int) $account['id'] : null;
  212. }
  213. ​
  214. function findUserAccountIdByCustomer(PDO $usersDb, string $provider, string $customerId): ?int
  215. {
  216. if ($customerId === '') {
  217. return null;
  218. }
  219. ​
  220. $statement = $usersDb->prepare(
  221. 'SELECT user_account_id FROM subscriptions WHERE provider = :provider AND provider_customer_id = :customer ORDER BY id DESC LIMIT 1'
  222. );
  223. $statement->execute(['provider' => $provider, 'customer' => $customerId]);
  224. $userAccountId = $statement->fetchColumn();
  225. ​
  226. return $userAccountId !== false ? (int) $userAccountId : null;
  227. }
  228. ​
  229. function findSubscription(PDO $usersDb, string $provider, string $subscriptionId): ?array
  230. {
  231. $statement = $usersDb->prepare(
  232. 'SELECT id, provider_customer_id FROM subscriptions WHERE provider = :provider AND provider_subscription_id = :id LIMIT 1'
  233. );
  234. $statement->execute(['provider' => $provider, 'id' => $subscriptionId]);
  235. ​
  236. return $statement->fetch() ?: null;
  237. }
  238. ​
  239. function processLifetimeEvent(PDO $usersDb, string $provider, string $eventType, array $data): void
  240. {
  241. $paymentId = (string) ($data['id'] ?? '');
  242. if ($paymentId === '') {
  243. return;
  244. }
  245. ​
  246. if ($provider === 'polar' && $eventType === 'order.refunded') {
  247. $refund = $usersDb->prepare(
  248. "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE provider = :provider AND provider_subscription_id = :id AND plan_code = 'lifetime'"
  249. );
  250. $refund->execute(['provider' => $provider, 'id' => $paymentId]);
  251. return;
  252. }
  253. ​
  254. $isPaid = ($provider === 'stripe' && $eventType === 'checkout.session.completed'
  255. && ($data['payment_status'] ?? '') === 'paid' && ($data['mode'] ?? 'payment') === 'payment')
  256. || ($provider === 'polar' && $eventType === 'order.paid');
  257. ​
  258. if (!$isPaid) {
  259. return;
  260. }
  261. ​
  262. $userAccountId = findUserAccountId($usersDb, $data);
  263. if ($userAccountId === null || findSubscription($usersDb, $provider, $paymentId) !== null) {
  264. return;
  265. }
  266. ​
  267. $customerId = providerObjectId($data['customer'] ?? ($data['customer_id'] ?? ''));
  268. ​
  269. $insert = $usersDb->prepare(
  270. "INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, status, current_period_end)
  271. VALUES (:user_id, :provider, :customer_id, :payment_id, 'lifetime', 'active', NULL)"
  272. );
  273. $insert->execute([
  274. 'user_id' => $userAccountId,
  275. 'provider' => $provider,
  276. 'customer_id' => $customerId !== '' ? $customerId : null,
  277. 'payment_id' => $paymentId,
  278. ]);
  279. }
  280. ​
  281. function processStripeSubscriptionCheckout(PDO $usersDb, array $data): void
  282. {
  283. $subscriptionId = providerObjectId($data['subscription'] ?? '');
  284. if ($subscriptionId === '' || findSubscription($usersDb, 'stripe', $subscriptionId) !== null) {
  285. return;
  286. }
  287. ​
  288. $userAccountId = findUserAccountId($usersDb, $data);
  289. if ($userAccountId === null) {
  290. return;
  291. }
  292. ​
  293. $customerId = providerObjectId($data['customer'] ?? '');
  294. ​
  295. $insert = $usersDb->prepare(
  296. "INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, status, current_period_end)
  297. VALUES (:user_id, 'stripe', :customer_id, :sub_id, NULL, 'active', NULL)"
  298. );
  299. $insert->execute([
  300. 'user_id' => $userAccountId,
  301. 'customer_id' => $customerId !== '' ? $customerId : null,
  302. 'sub_id' => $subscriptionId,
  303. ]);
  304. }
  305. ​
  306. function subscriptionStatus(string $eventType, array $data): string
  307. {
  308. $statusMap = [
  309. 'active' => 'active',
  310. 'trialing' => 'trialing',
  311. 'past_due' => 'past_due',
  312. 'unpaid' => 'past_due',
  313. 'incomplete' => 'past_due',
  314. 'paused' => 'past_due',
  315. 'canceled' => 'canceled',
  316. 'cancelled' => 'canceled',
  317. 'revoked' => 'canceled',
  318. 'incomplete_expired' => 'expired',
  319. 'expired' => 'expired',
  320. ];
  321. ​
  322. $rawStatus = strtolower((string) ($data['status'] ?? ''));
  323. if (isset($statusMap[$rawStatus])) {
  324. return $statusMap[$rawStatus];
  325. }
  326. ​
  327. if (str_contains($eventType, 'deleted') || str_contains($eventType, 'revoked')) {
  328. return 'canceled';
  329. }
  330. ​
  331. return 'past_due';
  332. }
  333. ​
  334. function subscriptionPeriodEnd(array $data): ?string
  335. {
  336. $value = $data['current_period_end'] ?? ($data['items']['data'][0]['current_period_end'] ?? null);
  337. if ($value === null || $value === '') {
  338. return null;
  339. }
  340. ​
  341. $timestamp = is_numeric($value) ? (int) $value : strtotime((string) $value);
  342. ​
  343. return $timestamp !== false && $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : null;
  344. }
  345. ​
  346. function processSubscriptionEvent(PDO $usersDb, string $provider, string $eventType, array $data): void
  347. {
  348. $providerSubscriptionId = (string) ($data['id'] ?? '');
  349. if ($providerSubscriptionId === '') {
  350. return;
  351. }
  352. ​
  353. $providerCustomerId = providerObjectId($data['customer'] ?? ($data['customer_id'] ?? ''));
  354. $planCode = (string) ($data['plan']['id'] ?? ($data['items']['data'][0]['price']['id'] ?? ($data['product_id'] ?? '')));
  355. $status = subscriptionStatus($eventType, $data);
  356. $currentPeriodEnd = subscriptionPeriodEnd($data);
  357. ​
  358. $existing = findSubscription($usersDb, $provider, $providerSubscriptionId);
  359. ​
  360. if ($existing !== null) {
  361. $update = $usersDb->prepare(
  362. "UPDATE subscriptions SET status = :status, current_period_end = COALESCE(:period_end, current_period_end),
  363. provider_customer_id = COALESCE(provider_customer_id, :customer_id), plan_code = COALESCE(plan_code, :plan_code),
  364. canceled_at = IF(:status2 = 'canceled', COALESCE(canceled_at, NOW()), canceled_at) WHERE id = :id"
  365. );
  366. $update->execute([
  367. 'status' => $status,
  368. 'period_end' => $currentPeriodEnd,
  369. 'customer_id' => $providerCustomerId !== '' ? $providerCustomerId : null,
  370. 'plan_code' => $planCode !== '' ? $planCode : null,
  371. 'status2' => $status,
  372. 'id' => $existing['id'],
  373. ]);
  374. return;
  375. }
  376. ​
  377. $userAccountId = findUserAccountId($usersDb, $data) ?? findUserAccountIdByCustomer($usersDb, $provider, $providerCustomerId);
  378. if ($userAccountId === null) {
  379. return;
  380. }
  381. ​
  382. $insert = $usersDb->prepare(
  383. 'INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, status, current_period_end)
  384. VALUES (:user_id, :provider, :customer_id, :sub_id, :plan_code, :status, :period_end)'
  385. );
  386. $insert->execute([
  387. 'user_id' => $userAccountId,
  388. 'provider' => $provider,
  389. 'customer_id' => $providerCustomerId !== '' ? $providerCustomerId : null,
  390. 'sub_id' => $providerSubscriptionId,
  391. 'plan_code' => $planCode !== '' ? $planCode : null,
  392. 'status' => $status,
  393. 'period_end' => $currentPeriodEnd,
  394. ]);
  395. }
  396. ​