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>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
<?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.
|
||||
}
|
||||
Reference in New Issue
Block a user