prepare('SELECT 1 FROM stripe_events WHERE stripe_event_id = ? LIMIT 1'); $check->execute([$eventId]); if ($check->fetchColumn()) { http_response_code(200); echo 'Already processed'; exit; } try { $db->prepare('INSERT INTO stripe_events (stripe_event_id, type, payload, processed_at) VALUES (?, ?, ?, NOW())') ->execute([$eventId, $type, json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]); switch ($type) { case 'checkout.session.completed': handleCheckoutCompleted($db, $object); break; case 'customer.subscription.created': case 'customer.subscription.updated': handleSubscriptionChange($db, $object); break; case 'customer.subscription.deleted': handleSubscriptionDeleted($db, $object); break; case 'customer.subscription.trial_will_end': // Stripe sends the customer reminder email. We mirror state on subscription.updated/deleted. break; case 'invoice.paid': handleInvoicePaid($db, $object); break; case 'invoice.payment_failed': handleInvoiceFailed($db, $object); break; default: // Unhandled event types are fine — Stripe just needs a 2xx response. break; } http_response_code(200); echo 'OK'; } catch (Throwable $e) { error_log('[stripe-webhook] handler error: ' . $e->getMessage()); // Return 500 so Stripe retries — but the stripe_events row already exists, so a retry will be idempotent-skipped. // To re-process: delete the row before retry. http_response_code(500); echo 'Handler failure'; } // ── Handlers ──────────────────────────────────────────────────────────────── function handleCheckoutCompleted(PDO $db, array $session): void { $mode = (string)($session['mode'] ?? ''); $metadata = (array)($session['metadata'] ?? []); $userId = (int)($metadata['user_id'] ?? 0); if ($userId <= 0) { error_log('[stripe-webhook] checkout.session.completed missing user_id metadata'); return; } // Store Stripe customer ID for portal access later. $customerId = (string)($session['customer'] ?? ''); if ($customerId !== '') { $db->prepare('UPDATE user_tool_credits SET stripe_customer_id = ? WHERE user_id = ? AND (stripe_customer_id IS NULL OR stripe_customer_id = "")') ->execute([$customerId, $userId]); } if ($mode === 'payment') { // One-time topup — grant credits immediately. $sku = StripeClient::canonicalSku((string)($metadata['sku'] ?? '')); $credits = StripeClient::topupCredits($sku); if ($credits > 0) { FreeTier::awardBonus($userId, $credits, 'topup:' . $sku); } } // For mode='subscription', subscription.created will fire and update the tier. } function handleSubscriptionChange(PDO $db, array $sub): void { $subId = (string)($sub['id'] ?? ''); $status = (string)($sub['status'] ?? ''); $customerId = (string)($sub['customer'] ?? ''); $items = (array)($sub['items']['data'] ?? []); $priceId = (string)($items[0]['price']['id'] ?? ''); $tier = StripeClient::tierForPrice($priceId); if ($tier === null) { error_log('[stripe-webhook] unknown price_id on subscription: ' . $priceId); return; } $metadata = (array)($sub['metadata'] ?? []); $userId = (int)($metadata['user_id'] ?? 0); if ($userId <= 0) { // Fallback: look up by stripe_customer_id $stmt = $db->prepare('SELECT user_id FROM user_tool_credits WHERE stripe_customer_id = ? LIMIT 1'); $stmt->execute([$customerId]); $userId = (int)($stmt->fetchColumn() ?: 0); } if ($userId <= 0) { error_log('[stripe-webhook] subscription.* could not resolve user_id (sub=' . $subId . ', customer=' . $customerId . ')'); return; } $periodEndTs = (int)($sub['current_period_end'] ?? 0); $periodStartTs = (int)($sub['current_period_start'] ?? 0); $trialEndTs = (int)($sub['trial_end'] ?? 0); $periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null; $periodStartIso = $periodStartTs > 0 ? gmdate('Y-m-d H:i:s', $periodStartTs) : null; $trialEndIso = $trialEndTs > 0 ? gmdate('Y-m-d H:i:s', $trialEndTs) : null; // Upsert subscription ledger $db->prepare( 'INSERT INTO user_subscriptions (user_id, stripe_customer_id, stripe_subscription_id, tier, status, current_period_start, current_period_end) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), tier = VALUES(tier), current_period_start = VALUES(current_period_start), current_period_end = VALUES(current_period_end), stripe_customer_id = VALUES(stripe_customer_id)' )->execute([$userId, $customerId, $subId, $tier, $status, $periodStartIso, $periodEndIso]); // Only flip the live tier flag if subscription is active/trialing. if (in_array($status, ['active', 'trialing'], true)) { FreeTier::setTier($userId, $tier, $customerId, $subId, $periodEndIso, $status === 'trialing' ? $trialEndIso : null); } elseif (in_array($status, ['canceled', 'unpaid', 'incomplete_expired'], true)) { FreeTier::clearTier($userId); } } function handleSubscriptionDeleted(PDO $db, array $sub): void { $subId = (string)($sub['id'] ?? ''); $customerId = (string)($sub['customer'] ?? ''); $stmt = $db->prepare('SELECT user_id FROM user_subscriptions WHERE stripe_subscription_id = ? LIMIT 1'); $stmt->execute([$subId]); $userId = (int)($stmt->fetchColumn() ?: 0); if ($userId <= 0 && $customerId !== '') { $stmt = $db->prepare('SELECT user_id FROM user_tool_credits WHERE stripe_customer_id = ? LIMIT 1'); $stmt->execute([$customerId]); $userId = (int)($stmt->fetchColumn() ?: 0); } if ($userId > 0) { $db->prepare('UPDATE user_subscriptions SET status = ? WHERE stripe_subscription_id = ?') ->execute(['canceled', $subId]); FreeTier::clearTier($userId); } } function handleInvoicePaid(PDO $db, array $invoice): void { $subId = (string)($invoice['subscription'] ?? ''); if ($subId === '') return; $stmt = $db->prepare('SELECT user_id, tier FROM user_subscriptions WHERE stripe_subscription_id = ? LIMIT 1'); $stmt->execute([$subId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) return; $userId = (int)$row['user_id']; $tier = (string)$row['tier']; $periodEndTs = (int)($invoice['lines']['data'][0]['period']['end'] ?? 0); $periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null; FreeTier::refillForRenewal($userId, $tier, $periodEndIso); } function handleInvoiceFailed(PDO $db, array $invoice): void { $subId = (string)($invoice['subscription'] ?? ''); if ($subId === '') return; $db->prepare('UPDATE user_subscriptions SET status = ? WHERE stripe_subscription_id = ?') ->execute(['past_due', $subId]); // Don't downgrade yet — Stripe will retry. subscription.updated will fire if it fully fails. }