Files
dobetternorge-tools/api/stripe-webhook.php
T
daveadmin ba9cddf9a1 Add monetization spine + Build Your Own Case (Min Sak)
- Stripe: StripeClient.php, checkout/portal/webhook endpoints, idempotent event handling
- FreeTier: tier-aware credits (free/light/pro/pro_plus), bonus_balance, hourly caps per tier
- pricing.php + billing.php: 4-tier cards, 3 topups, Customer Portal, balance breakdown
- Min Sak: CaseStore.php, AzureDocIntelligence.php, AzureSearchAdmin.php — per-user hybrid RAG
- api/case/: upload, list, delete, ingest-callback (HMAC-auth'd from n8n)
- award-survey-credits: inter-site HMAC endpoint for dobetternorge.no survey bonus
- dashboard.php: tier badge, balance breakdown card, Min Sak CTA, survey CTA
- KorrespondAgent + all 3 other agents: use_my_case toggle wired to dbnToolsCaseContext()
- bootstrap.php: dbnToolsCaseContext(), dbnToolsIntersiteSecret(), dbnToolsCurrentTier()

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:52:54 +02:00

225 lines
8.3 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 '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);
$periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null;
$periodStartIso = $periodStartTs > 0 ? gmdate('Y-m-d H:i:s', $periodStartTs) : 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);
} 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.
}