231 lines
8.6 KiB
PHP
231 lines
8.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
|
require_once __DIR__ . '/../includes/StripeClient.php';
|
|
require_once __DIR__ . '/../includes/FreeTier.php';
|
|
|
|
// Webhook must accept POST only, must verify signature, must be idempotent.
|
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
|
http_response_code(405);
|
|
echo 'Method not allowed';
|
|
exit;
|
|
}
|
|
|
|
$payload = file_get_contents('php://input');
|
|
$sig = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
|
|
$secret = StripeClient::config('STRIPE_WEBHOOK_SECRET');
|
|
|
|
if ($payload === false || $payload === '' || $sig === '' || $secret === '') {
|
|
http_response_code(400);
|
|
error_log('[stripe-webhook] missing payload, signature or secret');
|
|
echo 'Bad request';
|
|
exit;
|
|
}
|
|
|
|
if (!StripeClient::verifyWebhookSignature($payload, $sig, $secret)) {
|
|
http_response_code(400);
|
|
error_log('[stripe-webhook] signature verification failed');
|
|
echo 'Invalid signature';
|
|
exit;
|
|
}
|
|
|
|
$event = json_decode($payload, true);
|
|
if (!is_array($event)) {
|
|
http_response_code(400);
|
|
echo 'Invalid JSON';
|
|
exit;
|
|
}
|
|
|
|
$eventId = (string)($event['id'] ?? '');
|
|
$type = (string)($event['type'] ?? '');
|
|
$object = (array)($event['data']['object'] ?? []);
|
|
|
|
if ($eventId === '' || $type === '') {
|
|
http_response_code(400);
|
|
echo 'Missing event metadata';
|
|
exit;
|
|
}
|
|
|
|
$db = dbnmDb();
|
|
|
|
// Idempotency: skip if we've already processed this event_id.
|
|
$check = $db->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 = (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.
|
|
}
|