v1.0.0.6
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/includes/config.php';
- require_once __DIR__ . '/includes/database.php';
- require_once __DIR__ . '/includes/site-front.php';
-
- const WEBHOOK_TOLERANCE_SECONDS = 300;
-
- header('Content-Type: application/json');
-
- $settings = SiteFront::settings();
- $provider = $settings['payment_provider'];
- $webhookSecret = $settings['payment_webhook_secret'];
-
- $rawBody = (string) file_get_contents('php://input');
-
- if ($webhookSecret === '') {
- http_response_code(503);
- echo json_encode(['error' => 'Webhook secret not configured.']);
- exit;
- }
-
- if ($provider === 'stripe') {
- $signatureHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
- if (!verifyStripeSignature($rawBody, $signatureHeader, $webhookSecret)) {
- http_response_code(400);
- echo json_encode(['error' => 'Invalid signature.']);
- exit;
- }
- } elseif ($provider === 'polar') {
- $polarEventId = (string) ($_SERVER['HTTP_WEBHOOK_ID'] ?? '');
- $polarTimestamp = (string) ($_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '');
- $signatureHeader = (string) ($_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');
- if (!verifyPolarSignature($rawBody, $polarEventId, $polarTimestamp, $signatureHeader, $webhookSecret)) {
- http_response_code(400);
- echo json_encode(['error' => 'Invalid signature.']);
- exit;
- }
- } else {
- http_response_code(400);
- echo json_encode(['error' => 'Unknown payment provider configured.']);
- exit;
- }
-
- $payload = json_decode($rawBody, true);
- if (!is_array($payload)) {
- http_response_code(400);
- echo json_encode(['error' => 'Invalid JSON payload.']);
- exit;
- }
-
- $eventId = $provider === 'polar' ? $polarEventId : (string) ($payload['id'] ?? '');
- $eventType = (string) ($payload['type'] ?? ($payload['event'] ?? ''));
-
- if ($eventId === '' || $eventType === '') {
- http_response_code(400);
- echo json_encode(['error' => 'Missing event id or type.']);
- exit;
- }
-
- $usersDb = Database::users();
-
- $existingStatement = $usersDb->prepare(
- 'SELECT id FROM payment_webhook_events WHERE provider = :provider AND event_id = :event_id LIMIT 1'
- );
- $existingStatement->execute(['provider' => $provider, 'event_id' => $eventId]);
- if ($existingStatement->fetch()) {
- echo json_encode(['status' => 'already_processed']);
- exit;
- }
-
- $insertEvent = $usersDb->prepare(
- 'INSERT INTO payment_webhook_events (provider, event_id, event_type, payload, processing_status) VALUES (:provider, :event_id, :event_type, :payload, :status)'
- );
- $insertEvent->execute([
- 'provider' => $provider,
- 'event_id' => $eventId,
- 'event_type' => $eventType,
- 'payload' => $rawBody,
- 'status' => 'received',
- ]);
- $webhookRowId = (int) $usersDb->lastInsertId();
-
- try {
- processWebhookEvent($usersDb, $provider, $eventType, $payload);
-
- $updateEvent = $usersDb->prepare(
- "UPDATE payment_webhook_events SET processing_status = 'processed', processed_at = NOW() WHERE id = :id"
- );
- $updateEvent->execute(['id' => $webhookRowId]);
-
- echo json_encode(['status' => 'processed']);
- } catch (Throwable $exception) {
- $updateEvent = $usersDb->prepare(
- "UPDATE payment_webhook_events SET processing_status = 'failed', processing_error = :error WHERE id = :id"
- );
- $updateEvent->execute(['id' => $webhookRowId, 'error' => substr($exception->getMessage(), 0, 500)]);
-
- http_response_code(500);
- echo json_encode(['error' => 'Processing failed.']);
- }
-
- function verifyStripeSignature(string $payload, string $signatureHeader, string $secret): bool
- {
- if ($signatureHeader === '') {
- return false;
- }
-
- $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 (!ctype_digit($timestamp) || $signatures === [] || abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
- return false;
- }
-
- $expectedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
- foreach ($signatures as $signature) {
- if (hash_equals($expectedSignature, $signature)) {
- return true;
- }
- }
-
- return false;
- }
-
- function verifyPolarSignature(string $payload, string $eventId, string $timestamp, string $signatureHeader, string $secret): bool
- {
- if ($eventId === '' || $signatureHeader === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
- return false;
- }
-
- $key = $secret;
- if (str_starts_with($secret, 'whsec_')) {
- $decoded = base64_decode(substr($secret, 6), true);
- if ($decoded !== false) {
- $key = $decoded;
- }
- }
-
- $expectedSignature = base64_encode(hash_hmac('sha256', $eventId . '.' . $timestamp . '.' . $payload, $key, true));
- foreach (preg_split('/\s+/', trim($signatureHeader)) ?: [] as $entry) {
- [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
- if ($version === 'v1' && hash_equals($expectedSignature, $signature)) {
- 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);
-
- if ($provider === 'stripe' && $normalizedType === 'checkout.session.completed' && ($data['mode'] ?? '') === 'subscription') {
- processStripeSubscriptionCheckout($usersDb, $data);
- return;
- }
-
- if (str_starts_with($normalizedType, 'customer.subscription.') || str_starts_with($normalizedType, 'subscription.')) {
- processSubscriptionEvent($usersDb, $provider, $normalizedType, $data);
- return;
- }
-
- if (SiteFront::settings()['subscription_interval'] === 'lifetime') {
- processLifetimeEvent($usersDb, $provider, $normalizedType, $data);
- }
- }
-
- function providerObjectId(mixed $value): string
- {
- return is_array($value) ? (string) ($value['id'] ?? '') : (string) ($value ?? '');
- }
-
- function findUserAccountId(PDO $usersDb, array $data): ?int
- {
- $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 email = :email LIMIT 1');
- $statement->execute(['email' => $email]);
- $account = $statement->fetch();
-
- return $account ? (int) $account['id'] : null;
- }
-
- function findUserAccountIdByCustomer(PDO $usersDb, string $provider, string $customerId): ?int
- {
- if ($customerId === '') {
- return null;
- }
-
- $statement = $usersDb->prepare(
- 'SELECT user_account_id FROM subscriptions WHERE provider = :provider AND provider_customer_id = :customer ORDER BY id DESC LIMIT 1'
- );
- $statement->execute(['provider' => $provider, 'customer' => $customerId]);
- $userAccountId = $statement->fetchColumn();
-
- return $userAccountId !== false ? (int) $userAccountId : null;
- }
-
- function findSubscription(PDO $usersDb, string $provider, string $subscriptionId): ?array
- {
- $statement = $usersDb->prepare(
- 'SELECT id, provider_customer_id FROM subscriptions WHERE provider = :provider AND provider_subscription_id = :id LIMIT 1'
- );
- $statement->execute(['provider' => $provider, 'id' => $subscriptionId]);
-
- return $statement->fetch() ?: 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') {
- $refund = $usersDb->prepare(
- "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE provider = :provider AND provider_subscription_id = :id AND plan_code = 'lifetime'"
- );
- $refund->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 || findSubscription($usersDb, $provider, $paymentId) !== null) {
- return;
- }
-
- $customerId = providerObjectId($data['customer'] ?? ($data['customer_id'] ?? ''));
-
- $insert = $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)"
- );
- $insert->execute([
- 'user_id' => $userAccountId,
- 'provider' => $provider,
- 'customer_id' => $customerId !== '' ? $customerId : null,
- 'payment_id' => $paymentId,
- ]);
- }
-
- function processStripeSubscriptionCheckout(PDO $usersDb, array $data): void
- {
- $subscriptionId = providerObjectId($data['subscription'] ?? '');
- if ($subscriptionId === '' || findSubscription($usersDb, 'stripe', $subscriptionId) !== null) {
- return;
- }
-
- $userAccountId = findUserAccountId($usersDb, $data);
- if ($userAccountId === null) {
- return;
- }
-
- $customerId = providerObjectId($data['customer'] ?? '');
-
- $insert = $usersDb->prepare(
- "INSERT INTO subscriptions (user_account_id, provider, provider_customer_id, provider_subscription_id, plan_code, status, current_period_end)
- VALUES (:user_id, 'stripe', :customer_id, :sub_id, NULL, 'active', NULL)"
- );
- $insert->execute([
- 'user_id' => $userAccountId,
- 'customer_id' => $customerId !== '' ? $customerId : null,
- 'sub_id' => $subscriptionId,
- ]);
- }
-
- function subscriptionStatus(string $eventType, array $data): string
- {
- $statusMap = [
- 'active' => 'active',
- 'trialing' => 'trialing',
- 'past_due' => 'past_due',
- 'unpaid' => 'past_due',
- 'incomplete' => 'past_due',
- 'paused' => 'past_due',
- 'canceled' => 'canceled',
- 'cancelled' => 'canceled',
- 'revoked' => 'canceled',
- 'incomplete_expired' => 'expired',
- 'expired' => 'expired',
- ];
-
- $rawStatus = strtolower((string) ($data['status'] ?? ''));
- if (isset($statusMap[$rawStatus])) {
- return $statusMap[$rawStatus];
- }
-
- if (str_contains($eventType, 'deleted') || str_contains($eventType, 'revoked')) {
- return 'canceled';
- }
-
- return 'past_due';
- }
-
- function subscriptionPeriodEnd(array $data): ?string
- {
- $value = $data['current_period_end'] ?? ($data['items']['data'][0]['current_period_end'] ?? null);
- if ($value === null || $value === '') {
- return null;
- }
-
- $timestamp = is_numeric($value) ? (int) $value : strtotime((string) $value);
-
- return $timestamp !== false && $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : null;
- }
-
- function processSubscriptionEvent(PDO $usersDb, string $provider, string $eventType, array $data): void
- {
- $providerSubscriptionId = (string) ($data['id'] ?? '');
- if ($providerSubscriptionId === '') {
- return;
- }
-
- $providerCustomerId = providerObjectId($data['customer'] ?? ($data['customer_id'] ?? ''));
- $planCode = (string) ($data['plan']['id'] ?? ($data['items']['data'][0]['price']['id'] ?? ($data['product_id'] ?? '')));
- $status = subscriptionStatus($eventType, $data);
- $currentPeriodEnd = subscriptionPeriodEnd($data);
-
- $existing = findSubscription($usersDb, $provider, $providerSubscriptionId);
-
- if ($existing !== null) {
- $update = $usersDb->prepare(
- "UPDATE subscriptions SET status = :status, current_period_end = COALESCE(:period_end, current_period_end),
- provider_customer_id = COALESCE(provider_customer_id, :customer_id), plan_code = COALESCE(plan_code, :plan_code),
- canceled_at = IF(:status2 = 'canceled', COALESCE(canceled_at, NOW()), canceled_at) WHERE id = :id"
- );
- $update->execute([
- 'status' => $status,
- 'period_end' => $currentPeriodEnd,
- 'customer_id' => $providerCustomerId !== '' ? $providerCustomerId : null,
- 'plan_code' => $planCode !== '' ? $planCode : null,
- 'status2' => $status,
- 'id' => $existing['id'],
- ]);
- return;
- }
-
- $userAccountId = findUserAccountId($usersDb, $data) ?? findUserAccountIdByCustomer($usersDb, $provider, $providerCustomerId);
- if ($userAccountId === null) {
- return;
- }
-
- $insert = $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, :sub_id, :plan_code, :status, :period_end)'
- );
- $insert->execute([
- 'user_id' => $userAccountId,
- 'provider' => $provider,
- 'customer_id' => $providerCustomerId !== '' ? $providerCustomerId : null,
- 'sub_id' => $providerSubscriptionId,
- 'plan_code' => $planCode !== '' ? $planCode : null,
- 'status' => $status,
- 'period_end' => $currentPeriodEnd,
- ]);
- }
-