v1.0.0.1
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/includes/config.php';
- require_once __DIR__ . '/includes/database.php';
- require_once __DIR__ . '/includes/site-front.php';
-
- header('Content-Type: application/json');
-
- const WEBHOOK_TOLERANCE_SECONDS = 300;
-
- $settings = SiteFront::settings();
- $rawBody = (string) file_get_contents('php://input');
-
- $stripeSignature = (string) ($_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '');
- $polarSignature = (string) ($_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');
-
- if ($stripeSignature !== '') {
- $provider = 'stripe';
- $webhookSecret = (string) $settings['payment_webhook_secret'];
- } elseif ($polarSignature !== '') {
- $provider = 'polar';
- $webhookSecret = (string) ($settings['payment_polar_webhook_secret'] !== '' ? $settings['payment_polar_webhook_secret'] : $settings['payment_webhook_secret']);
- } else {
- webhookRespond(400, ['error' => 'Missing signature header.']);
- }
-
- if ($webhookSecret === '') {
- webhookRespond(503, ['error' => 'Webhook secret not configured.']);
- }
-
- if ($provider === 'stripe') {
- if (!verifyStripeSignature($rawBody, $stripeSignature, $webhookSecret)) {
- webhookRespond(400, ['error' => 'Invalid signature.']);
- }
- } else {
- $polarWebhookId = (string) ($_SERVER['HTTP_WEBHOOK_ID'] ?? '');
- $polarTimestamp = (string) ($_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '');
- if (!verifyStandardWebhookSignature($rawBody, $polarWebhookId, $polarTimestamp, $polarSignature, $webhookSecret)) {
- webhookRespond(400, ['error' => 'Invalid signature.']);
- }
- }
-
- $payload = json_decode($rawBody, true);
- if (!is_array($payload)) {
- webhookRespond(400, ['error' => 'Invalid JSON payload.']);
- }
-
- $eventId = $provider === 'polar' ? (string) ($_SERVER['HTTP_WEBHOOK_ID'] ?? '') : (string) ($payload['id'] ?? '');
- $eventType = (string) ($payload['type'] ?? ($payload['event'] ?? ''));
-
- if ($eventId === '' || $eventType === '') {
- webhookRespond(400, ['error' => 'Missing event id or type.']);
- }
-
- $usersDb = Database::users();
-
- $existingStatement = $usersDb->prepare(
- 'SELECT id, processing_status FROM payment_webhook_events WHERE provider = :provider AND event_id = :event_id LIMIT 1'
- );
- $existingStatement->execute(['provider' => $provider, 'event_id' => $eventId]);
- $existingEvent = $existingStatement->fetch();
-
- if ($existingEvent && in_array($existingEvent['processing_status'], ['processed', 'ignored'], true)) {
- webhookRespond(200, ['status' => 'already_processed']);
- }
-
- if ($existingEvent) {
- $webhookRowId = (int) $existingEvent['id'];
- $usersDb->prepare("UPDATE payment_webhook_events SET processing_status = 'received', processing_error = NULL WHERE id = :id")
- ->execute(['id' => $webhookRowId]);
- } else {
- try {
- $usersDb->prepare(
- 'INSERT INTO payment_webhook_events (provider, event_id, event_type, payload, processing_status) VALUES (:provider, :event_id, :event_type, :payload, :status)'
- )->execute([
- 'provider' => $provider,
- 'event_id' => $eventId,
- 'event_type' => $eventType,
- 'payload' => $rawBody,
- 'status' => 'received',
- ]);
- } catch (PDOException $exception) {
- webhookRespond(200, ['status' => 'already_processing']);
- }
- $webhookRowId = (int) $usersDb->lastInsertId();
- }
-
- try {
- processWebhookEvent($usersDb, $provider, $eventType, $payload);
-
- $usersDb->prepare("UPDATE payment_webhook_events SET processing_status = 'processed', processed_at = NOW() WHERE id = :id")
- ->execute(['id' => $webhookRowId]);
-
- webhookRespond(200, ['status' => 'processed']);
- } catch (Throwable $exception) {
- error_log('StocketBase webhook: ' . $exception->getMessage());
- $usersDb->prepare("UPDATE payment_webhook_events SET processing_status = 'failed', processing_error = :error WHERE id = :id")
- ->execute(['id' => $webhookRowId, 'error' => substr($exception->getMessage(), 0, 500)]);
-
- webhookRespond(500, ['error' => 'Processing failed.']);
- }
-
- function webhookRespond(int $status, array $body): never
- {
- http_response_code($status);
- echo json_encode($body);
- exit;
- }
-
- function verifyStripeSignature(string $payload, string $signatureHeader, string $secret): bool
- {
- $timestamp = '';
- $signatures = [];
- foreach (explode(',', $signatureHeader) as $piece) {
- $pair = explode('=', trim($piece), 2);
- if (count($pair) !== 2) {
- continue;
- }
- if ($pair[0] === 't') {
- $timestamp = $pair[1];
- } elseif ($pair[0] === 'v1') {
- $signatures[] = $pair[1];
- }
- }
-
- if ($timestamp === '' || !ctype_digit($timestamp) || empty($signatures)) {
- return false;
- }
-
- if (abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
- return false;
- }
-
- $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
- foreach ($signatures as $signature) {
- if (hash_equals($expected, $signature)) {
- return true;
- }
- }
-
- return false;
- }
-
- function verifyStandardWebhookSignature(string $payload, string $webhookId, string $timestamp, string $signatureHeader, string $secret): bool
- {
- if ($webhookId === '' || $timestamp === '' || !ctype_digit($timestamp)) {
- return false;
- }
-
- if (abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
- return false;
- }
-
- $key = str_starts_with($secret, 'whsec_') ? (string) base64_decode(substr($secret, 6), true) : $secret;
- if ($key === '') {
- return false;
- }
-
- $expected = base64_encode(hash_hmac('sha256', $webhookId . '.' . $timestamp . '.' . $payload, $key, true));
-
- foreach (preg_split('/\s+/', trim($signatureHeader)) ?: [] as $entry) {
- $parts = explode(',', $entry, 2);
- if (count($parts) === 2 && $parts[0] === 'v1' && hash_equals($expected, $parts[1])) {
- return true;
- }
- }
-
- return false;
- }
-
- function processWebhookEvent(PDO $usersDb, string $provider, string $eventType, array $payload): void
- {
- $data = $payload['data']['object'] ?? ($payload['data'] ?? $payload);
- if (!is_array($data)) {
- return;
- }
-
- $normalizedType = strtolower($eventType);
- $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
- $orderId = isset($metadata['order_id']) ? (int) $metadata['order_id'] : 0;
-
- if ($orderId > 0) {
- $isStripePaid = $provider === 'stripe'
- && (($normalizedType === 'checkout.session.completed' && ($data['payment_status'] ?? '') === 'paid')
- || $normalizedType === 'checkout.session.async_payment_succeeded');
- $isPolarPaid = $provider === 'polar' && $normalizedType === 'order.paid';
-
- if ($isStripePaid || $isPolarPaid) {
- processOrderPaymentEvent($usersDb, $provider, $data, $orderId);
- return;
- }
-
- if ($provider === 'stripe' && in_array($normalizedType, ['checkout.session.expired', 'checkout.session.async_payment_failed'], true)) {
- markOrderFailed($usersDb, $orderId, $normalizedType === 'checkout.session.expired' ? 'Checkout session expired' : 'Payment failed');
- return;
- }
- }
-
- if ($provider === 'stripe' && $normalizedType === 'checkout.session.completed' && ($data['mode'] ?? '') === 'subscription') {
- processStripeSubscriptionCheckout($usersDb, $data);
- return;
- }
-
- if ($provider === 'stripe' && ($metadata['checkout_kind'] ?? '') === 'one_time'
- && (($normalizedType === 'checkout.session.completed' && ($data['payment_status'] ?? '') === 'paid') || $normalizedType === 'checkout.session.async_payment_succeeded')) {
- $productId = (int) ($metadata['product_id'] ?? 0);
- $customer = $data['customer'] ?? '';
- upsertSubscription($usersDb, 'stripe', (string) ($data['id'] ?? ''), findUserAccountId($usersDb, $data), [
- 'status' => 'active',
- 'provider_customer_id' => is_array($customer) ? (string) ($customer['id'] ?? '') : ((string) $customer !== '' ? (string) $customer : null),
- 'product_id' => $productId > 0 ? $productId : null,
- 'plan_code' => 'lifetime',
- ]);
- return;
- }
-
- if (str_contains($normalizedType, 'subscription')) {
- processSubscriptionEvent($usersDb, $provider, $normalizedType, $data);
- return;
- }
-
- if (($orderId === 0) && SiteFront::settings()['subscription_interval'] === 'lifetime') {
- processLifetimeEvent($usersDb, $provider, $normalizedType, $data);
- }
- }
-
- function markOrderFailed(PDO $usersDb, int $orderId, string $note): void
- {
- $update = $usersDb->prepare("UPDATE orders SET status = 'failed' WHERE id = :id AND status = 'pending'");
- $update->execute(['id' => $orderId]);
- if ($update->rowCount() > 0) {
- $usersDb->prepare("INSERT INTO order_status_history (order_id, status, note) VALUES (:order_id, 'failed', :note)")
- ->execute(['order_id' => $orderId, 'note' => $note]);
- }
- }
-
- function processOrderPaymentEvent(PDO $usersDb, string $provider, array $data, int $orderId): void
- {
- $orderStatement = $usersDb->prepare('SELECT * FROM orders WHERE id = :id LIMIT 1');
- $orderStatement->execute(['id' => $orderId]);
- $order = $orderStatement->fetch();
-
- if (!$order || !in_array($order['status'], ['pending', 'failed'], true)) {
- return;
- }
-
- if ($provider === 'stripe') {
- $paidAmount = isset($data['amount_total']) ? (int) $data['amount_total'] : null;
- $paidCurrency = strtoupper((string) ($data['currency'] ?? ''));
- if ($paidAmount === null || $paidAmount !== (int) $order['total_cents'] || ($paidCurrency !== '' && $paidCurrency !== strtoupper((string) $order['currency']))) {
- $usersDb->prepare("INSERT INTO order_status_history (order_id, status, note) VALUES (:order_id, :status, :note)")
- ->execute([
- 'order_id' => $orderId,
- 'status' => $order['status'],
- 'note' => 'Payment amount mismatch: received ' . (string) $paidAmount . ' ' . $paidCurrency . ', expected ' . (int) $order['total_cents'] . ' ' . $order['currency'],
- ]);
- throw new RuntimeException('Payment amount mismatch for order ' . $orderId);
- }
- }
-
- $providerCheckoutId = (string) ($data['id'] ?? '');
- $paymentIntent = $data['payment_intent'] ?? null;
- $providerPaymentId = is_array($paymentIntent) ? (string) ($paymentIntent['id'] ?? '') : (string) ($paymentIntent ?? ($data['id'] ?? ''));
- $customer = $data['customer'] ?? ($data['customer_id'] ?? '');
- $providerCustomerId = is_array($customer) ? (string) ($customer['id'] ?? '') : (string) $customer;
-
- $updateOrder = $usersDb->prepare(
- "UPDATE orders SET status = 'paid', provider = :provider, provider_checkout_id = :checkout_id, provider_payment_id = :payment_id, provider_customer_id = :customer_id
- WHERE id = :id AND status IN ('pending', 'failed')"
- );
- $updateOrder->execute([
- 'provider' => $provider,
- 'checkout_id' => $providerCheckoutId !== '' ? $providerCheckoutId : null,
- 'payment_id' => $providerPaymentId !== '' ? $providerPaymentId : null,
- 'customer_id' => $providerCustomerId !== '' ? $providerCustomerId : null,
- 'id' => $orderId,
- ]);
-
- if ($updateOrder->rowCount() === 0) {
- return;
- }
-
- $usersDb->prepare(
- "INSERT INTO order_status_history (order_id, status, note) VALUES (:order_id, 'paid', 'Payment confirmed via webhook')"
- )->execute(['order_id' => $orderId]);
-
- $itemsStatement = $usersDb->prepare('SELECT * FROM order_items WHERE order_id = :id');
- $itemsStatement->execute(['id' => $orderId]);
- $items = $itemsStatement->fetchAll();
-
- $siteDb = Database::site();
-
- foreach ($items as $item) {
- $productId = (int) $item['product_id'];
- $variantId = $item['variant_id'] !== null ? (int) $item['variant_id'] : null;
- $quantity = (int) $item['quantity'];
-
- $productStatement = $siteDb->prepare('SELECT track_inventory FROM products WHERE id = :id LIMIT 1');
- $productStatement->execute(['id' => $productId]);
- $product = $productStatement->fetch();
-
- if ($product && (int) $product['track_inventory'] === 1) {
- $variantUpdated = 0;
- if ($variantId !== null) {
- $variantUpdate = $siteDb->prepare(
- 'UPDATE product_variants SET stock_quantity = GREATEST(0, stock_quantity - :qty) WHERE id = :id AND stock_quantity IS NOT NULL'
- );
- $variantUpdate->execute(['qty' => $quantity, 'id' => $variantId]);
- $variantUpdated = $variantUpdate->rowCount();
- }
- if ($variantUpdated === 0) {
- $siteDb->prepare(
- 'UPDATE products SET stock_quantity = GREATEST(0, stock_quantity - :qty) WHERE id = :id AND stock_quantity IS NOT NULL'
- )->execute(['qty' => $quantity, 'id' => $productId]);
- }
- }
-
- $siteDb->prepare('UPDATE products SET sales_count = sales_count + :qty WHERE id = :id')
- ->execute(['qty' => $quantity, 'id' => $productId]);
- }
-
- if (!empty($order['discount_code'])) {
- $siteDb->prepare(
- 'UPDATE discounts SET times_redeemed = times_redeemed + 1 WHERE UPPER(code) = UPPER(:code)'
- )->execute(['code' => $order['discount_code']]);
- }
-
- $downloadLinks = [];
- try {
- require_once __DIR__ . '/includes/digital-files.php';
- $downloadLinks = DigitalFiles::grantsForOrder($usersDb, $order, $items);
- } catch (Throwable $grantException) {
- error_log('StocketBase webhook: could not create download access — ' . $grantException->getMessage());
- }
-
- try {
- sendOrderConfirmationEmail($usersDb, $order, $items, $downloadLinks);
- } catch (Throwable $mailException) {
- error_log('StocketBase webhook: order confirmation email failed — ' . $mailException->getMessage());
- }
- }
-
- function sendOrderConfirmationEmail(PDO $usersDb, array $order, array $items, array $downloadLinks = []): void
- {
- if (!is_file(__DIR__ . '/includes/mailer.php') || !is_file(__DIR__ . '/includes/mail-templates.php')) {
- return;
- }
-
- require_once __DIR__ . '/includes/mailer.php';
- require_once __DIR__ . '/includes/mail-templates.php';
-
- if (!class_exists('Mailer') || !class_exists('MailTemplates')) {
- return;
- }
-
- $recipientEmail = (string) ($order['guest_email'] ?? '');
- if ($recipientEmail === '' && $order['user_account_id'] !== null) {
- $accountStatement = $usersDb->prepare('SELECT email FROM user_accounts WHERE id = :id LIMIT 1');
- $accountStatement->execute(['id' => $order['user_account_id']]);
- $recipientEmail = (string) $accountStatement->fetchColumn();
- }
-
- if ($recipientEmail !== '') {
- $email = MailTemplates::orderConfirmationEmail($order, $items, $downloadLinks);
- Mailer::send($recipientEmail, $email['subject'], $email['html']);
- }
- }
-
- function findUserAccountId(PDO $usersDb, array $data): ?int
- {
- $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
- $metadataUserId = (int) ($metadata['user_account_id'] ?? 0);
- if ($metadataUserId > 0) {
- $statement = $usersDb->prepare('SELECT id FROM user_accounts WHERE id = :id LIMIT 1');
- $statement->execute(['id' => $metadataUserId]);
- if ($statement->fetch()) {
- return $metadataUserId;
- }
- }
-
- $email = strtolower(trim((string) (
- $data['customer_email'] ??
- $data['email'] ??
- $data['customer_details']['email'] ??
- (is_array($data['customer'] ?? null) ? ($data['customer']['email'] ?? null) : null) ??
- ''
- )));
-
- if ($email === '') {
- return null;
- }
-
- $statement = $usersDb->prepare('SELECT id FROM user_accounts WHERE LOWER(email) = :email LIMIT 1');
- $statement->execute(['email' => $email]);
- $account = $statement->fetch();
-
- return $account ? (int) $account['id'] : null;
- }
-
- function mapSubscriptionStatus(string $eventType, array $data): ?string
- {
- if (str_contains($eventType, 'deleted') || str_contains($eventType, 'revoked') || str_contains($eventType, 'canceled') || str_contains($eventType, 'cancelled')) {
- return 'canceled';
- }
-
- return match (strtolower((string) ($data['status'] ?? ''))) {
- 'active' => 'active',
- 'trialing' => 'trialing',
- 'past_due', 'unpaid', 'incomplete' => 'past_due',
- 'canceled', 'cancelled', 'paused' => 'canceled',
- 'incomplete_expired', 'expired' => 'expired',
- default => null,
- };
- }
-
- function subscriptionPeriodEnd(array $data): ?string
- {
- $raw = $data['current_period_end'] ?? ($data['items']['data'][0]['current_period_end'] ?? null);
- if (empty($raw)) {
- return null;
- }
-
- $timestamp = is_numeric($raw) ? (int) $raw : strtotime((string) $raw);
-
- return $timestamp ? date('Y-m-d H:i:s', $timestamp) : null;
- }
-
- function upsertSubscription(PDO $usersDb, string $provider, string $subscriptionId, ?int $userAccountId, array $fields): void
- {
- $existingStatement = $usersDb->prepare(
- 'SELECT id FROM subscriptions WHERE provider = :provider AND provider_subscription_id = :sub_id LIMIT 1'
- );
- $existingStatement->execute(['provider' => $provider, 'sub_id' => $subscriptionId]);
- $existing = $existingStatement->fetch();
-
- if ($existing) {
- $sets = [];
- $params = ['id' => $existing['id']];
- foreach (['status', 'current_period_end', 'provider_customer_id', 'product_id', 'plan_code'] as $column) {
- if (array_key_exists($column, $fields) && $fields[$column] !== null) {
- $sets[] = $column . ' = :' . $column;
- $params[$column] = $fields[$column];
- }
- }
- if (($fields['status'] ?? null) === 'canceled') {
- $sets[] = 'canceled_at = COALESCE(canceled_at, NOW())';
- }
- if (!empty($sets)) {
- $usersDb->prepare('UPDATE subscriptions SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params);
- }
- return;
- }
-
- if ($userAccountId === null) {
- return;
- }
-
- $usersDb->prepare(
- 'INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, product_id, status, current_period_end)
- VALUES (:user_id, :provider, :customer_id, :sub_id, :plan_code, :product_id, :status, :period_end)'
- )->execute([
- 'user_id' => $userAccountId,
- 'provider' => $provider,
- 'customer_id' => $fields['provider_customer_id'] ?? null,
- 'sub_id' => $subscriptionId,
- 'plan_code' => $fields['plan_code'] ?? null,
- 'product_id' => $fields['product_id'] ?? null,
- 'status' => $fields['status'] ?? 'active',
- 'period_end' => $fields['current_period_end'] ?? null,
- ]);
- }
-
- function processStripeSubscriptionCheckout(PDO $usersDb, array $data): void
- {
- $subscriptionId = is_array($data['subscription'] ?? null) ? (string) ($data['subscription']['id'] ?? '') : (string) ($data['subscription'] ?? '');
- if ($subscriptionId === '') {
- return;
- }
-
- $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
- $productId = (int) ($metadata['product_id'] ?? 0);
- $customer = $data['customer'] ?? '';
-
- upsertSubscription($usersDb, 'stripe', $subscriptionId, findUserAccountId($usersDb, $data), [
- 'status' => 'active',
- 'provider_customer_id' => is_array($customer) ? (string) ($customer['id'] ?? '') : ((string) $customer !== '' ? (string) $customer : null),
- 'product_id' => $productId > 0 ? $productId : null,
- 'plan_code' => $productId > 0 ? (string) $productId : null,
- ]);
- }
-
- function processSubscriptionEvent(PDO $usersDb, string $provider, string $eventType, array $data): void
- {
- $subscriptionId = (string) ($data['id'] ?? '');
- if ($subscriptionId === '') {
- return;
- }
-
- $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
- $productId = (int) ($metadata['product_id'] ?? 0);
- $customer = $data['customer'] ?? ($data['customer_id'] ?? '');
- $customerId = is_array($customer) ? (string) ($customer['id'] ?? '') : (string) $customer;
- $planCode = (string) ($data['plan']['id'] ?? ($data['product_id'] ?? ''));
-
- upsertSubscription($usersDb, $provider, $subscriptionId, findUserAccountId($usersDb, $data), [
- 'status' => mapSubscriptionStatus($eventType, $data),
- 'current_period_end' => subscriptionPeriodEnd($data),
- 'provider_customer_id' => $customerId !== '' ? $customerId : null,
- 'product_id' => $productId > 0 ? $productId : null,
- 'plan_code' => $planCode !== '' ? $planCode : null,
- ]);
- }
-
- function processLifetimeEvent(PDO $usersDb, string $provider, string $eventType, array $data): void
- {
- $paymentId = (string) ($data['id'] ?? '');
- if ($paymentId === '') {
- return;
- }
-
- if ($provider === 'polar' && $eventType === 'order.refunded') {
- $usersDb->prepare(
- "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE provider = :provider AND provider_subscription_id = :id AND plan_code = 'lifetime'"
- )->execute(['provider' => $provider, 'id' => $paymentId]);
- return;
- }
-
- $isPaid = ($provider === 'stripe' && $eventType === 'checkout.session.completed'
- && ($data['payment_status'] ?? '') === 'paid' && ($data['mode'] ?? 'payment') === 'payment')
- || ($provider === 'polar' && $eventType === 'order.paid');
-
- if (!$isPaid) {
- return;
- }
-
- $userAccountId = findUserAccountId($usersDb, $data);
- if ($userAccountId === null) {
- return;
- }
-
- $existing = $usersDb->prepare(
- 'SELECT id FROM subscriptions WHERE provider = :provider AND provider_subscription_id = :id LIMIT 1'
- );
- $existing->execute(['provider' => $provider, 'id' => $paymentId]);
- if ($existing->fetch()) {
- return;
- }
-
- $customer = $data['customer'] ?? ($data['customer_id'] ?? '');
- $customerId = is_array($customer) ? (string) ($customer['id'] ?? '') : (string) $customer;
-
- $usersDb->prepare(
- "INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, status, current_period_end)
- VALUES (:user_id, :provider, :customer_id, :payment_id, 'lifetime', 'active', NULL)"
- )->execute([
- 'user_id' => $userAccountId,
- 'provider' => $provider,
- 'customer_id' => $customerId !== '' ? $customerId : null,
- 'payment_id' => $paymentId,
- ]);
- }
-