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,104 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Inter-site endpoint hit by dobetternorge.no after a successful survey submit.
|
||||
* Awards +25 bonus credits to the matching user_tool_credits row.
|
||||
*
|
||||
* Auth: HMAC-SHA256 over (timestamp + "." + payload_json) using INTERSITE_HMAC_SECRET.
|
||||
* Headers required:
|
||||
* X-Intersite-Timestamp: unix seconds (must be within ±300s of server time)
|
||||
* X-Intersite-Signature: hex hmac-sha256
|
||||
*
|
||||
* Body: { "email": "...", "user_id": int|null, "credits": 25, "source": "survey" }
|
||||
*
|
||||
* Idempotency: refuses to award twice for the same user_id (uses survey_completed_at).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/FreeTier.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
|
||||
$secret = dbnToolsIntersiteSecret();
|
||||
if ($secret === '') {
|
||||
dbnToolsError('Intersite secret not configured on this server.', 500, 'intersite_misconfigured');
|
||||
}
|
||||
|
||||
$ts = (int)($_SERVER['HTTP_X_INTERSITE_TIMESTAMP'] ?? 0);
|
||||
$sig = (string)($_SERVER['HTTP_X_INTERSITE_SIGNATURE'] ?? '');
|
||||
if ($ts === 0 || $sig === '') {
|
||||
dbnToolsError('Missing intersite auth headers.', 401, 'missing_signature');
|
||||
}
|
||||
if (abs(time() - $ts) > 300) {
|
||||
dbnToolsError('Stale intersite timestamp.', 401, 'stale_timestamp');
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
dbnToolsError('Empty body.', 400, 'empty_body');
|
||||
}
|
||||
|
||||
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
dbnToolsError('Bad intersite signature.', 401, 'bad_signature');
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
dbnToolsError('Invalid JSON body.', 400, 'invalid_json');
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string)($data['email'] ?? '')));
|
||||
$userIdHint = isset($data['user_id']) ? (int)$data['user_id'] : 0;
|
||||
$credits = (int)($data['credits'] ?? 25);
|
||||
$source = (string)($data['source'] ?? 'survey');
|
||||
|
||||
if ($credits < 1 || $credits > 100) {
|
||||
dbnToolsError('Credits out of range.', 400, 'bad_credits');
|
||||
}
|
||||
if ($email === '' && $userIdHint <= 0) {
|
||||
dbnToolsError('Need email or user_id.', 400, 'missing_user');
|
||||
}
|
||||
|
||||
$db = dbnmDb();
|
||||
|
||||
// Resolve user_id from hint or email
|
||||
$userId = $userIdHint;
|
||||
if ($userId <= 0 && $email !== '') {
|
||||
$stmt = $db->prepare('SELECT id FROM users WHERE LOWER(email) = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
$userId = (int)($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
if ($userId <= 0) {
|
||||
// No account yet — that's fine, dobetternorge.no will magic-link them; we'll award on next login.
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'awarded' => false,
|
||||
'pending' => true,
|
||||
'message' => 'User not yet registered — will award on first login.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Idempotency: don't double-award
|
||||
if (FreeTier::hasCompletedSurvey($userId)) {
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'awarded' => false,
|
||||
'reason' => 'already_completed',
|
||||
'balance' => FreeTier::balance($userId),
|
||||
]);
|
||||
}
|
||||
|
||||
FreeTier::ensureRow($userId);
|
||||
$newBalance = FreeTier::awardBonus($userId, $credits, $source);
|
||||
FreeTier::markSurveyCompleted($userId);
|
||||
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'awarded' => true,
|
||||
'credits' => $credits,
|
||||
'balance' => $newBalance,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../../includes/CaseStore.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
dbnToolsError('Auth required.', 401, 'auth_required');
|
||||
}
|
||||
|
||||
$input = dbnToolsJsonInput(500);
|
||||
$docId = (int)($input['doc_id'] ?? 0);
|
||||
if ($docId <= 0) {
|
||||
dbnToolsError('doc_id required.', 400, 'bad_input');
|
||||
}
|
||||
|
||||
$ok = CaseStore::deleteDocument($userId, $docId);
|
||||
if (!$ok) {
|
||||
dbnToolsError('Dokumentet finnes ikke, eller er allerede slettet.', 404, 'not_found');
|
||||
}
|
||||
|
||||
dbnToolsRespond(['ok' => true, 'doc_id' => $docId]);
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Called by the n8n Case Ingest workflow after a document is OCR'd + indexed.
|
||||
* Auth: HMAC-SHA256 (same as award-survey-credits, shared INTERSITE_HMAC_SECRET).
|
||||
*
|
||||
* Body: { doc_id, status, page_count, doc_type, detected_date, parties, error_msg }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
|
||||
$secret = dbnToolsIntersiteSecret();
|
||||
if ($secret === '') {
|
||||
dbnToolsError('Intersite secret not configured.', 500, 'misconfigured');
|
||||
}
|
||||
|
||||
$ts = (int)($_SERVER['HTTP_X_INTERSITE_TIMESTAMP'] ?? 0);
|
||||
$sig = (string)($_SERVER['HTTP_X_INTERSITE_SIGNATURE'] ?? '');
|
||||
if ($ts === 0 || $sig === '' || abs(time() - $ts) > 300) {
|
||||
dbnToolsError('Bad intersite auth.', 401, 'bad_auth');
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
dbnToolsError('Empty body.', 400, 'empty');
|
||||
}
|
||||
if (!hash_equals(hash_hmac('sha256', $ts . '.' . $raw, $secret), $sig)) {
|
||||
dbnToolsError('Bad signature.', 401, 'bad_sig');
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
dbnToolsError('Invalid JSON.', 400, 'invalid_json');
|
||||
}
|
||||
|
||||
$docId = (int)($data['doc_id'] ?? 0);
|
||||
$status = (string)($data['status'] ?? 'failed');
|
||||
$pageCount = isset($data['page_count']) ? (int)$data['page_count'] : null;
|
||||
$docType = isset($data['doc_type']) ? (string)$data['doc_type'] : null;
|
||||
$detectedDate = isset($data['detected_date']) ? (string)$data['detected_date'] : null;
|
||||
$parties = $data['parties'] ?? null;
|
||||
$errorMsg = isset($data['error_msg']) ? (string)$data['error_msg'] : null;
|
||||
|
||||
if ($docId <= 0) {
|
||||
dbnToolsError('doc_id required.', 400, 'bad_input');
|
||||
}
|
||||
|
||||
$db = dbnmDb();
|
||||
$db->prepare(
|
||||
'UPDATE case_documents
|
||||
SET ocr_status = ?,
|
||||
page_count = COALESCE(?, page_count),
|
||||
doc_type = COALESCE(?, doc_type),
|
||||
detected_date = COALESCE(?, detected_date),
|
||||
parties = COALESCE(?, parties),
|
||||
ocr_error = COALESCE(?, ocr_error),
|
||||
indexed_at = CASE WHEN ? = "ready" THEN NOW() ELSE indexed_at END
|
||||
WHERE id = ?'
|
||||
)->execute([
|
||||
$status,
|
||||
$pageCount,
|
||||
$docType,
|
||||
$detectedDate,
|
||||
$parties !== null ? json_encode($parties, JSON_UNESCAPED_UNICODE) : null,
|
||||
$errorMsg,
|
||||
$status,
|
||||
$docId,
|
||||
]);
|
||||
|
||||
dbnToolsRespond(['ok' => true]);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../../includes/CaseStore.php';
|
||||
|
||||
dbnToolsRequireMethod('GET');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
dbnToolsError('Auth required.', 401, 'auth_required');
|
||||
}
|
||||
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'docs' => CaseStore::listDocs($userId),
|
||||
]);
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../../includes/FreeTier.php';
|
||||
require_once __DIR__ . '/../../includes/CaseStore.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
dbnToolsError('Auth required.', 401, 'auth_required');
|
||||
}
|
||||
|
||||
if (empty($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
dbnToolsError('No file uploaded or upload error.', 400, 'no_file');
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
$size = (int)$file['size'];
|
||||
$tmp = (string)$file['tmp_name'];
|
||||
$name = (string)$file['name'];
|
||||
|
||||
if ($size <= 0 || $size > 25 * 1024 * 1024) {
|
||||
dbnToolsError('Filen må være mellom 1 byte og 25 MB.', 413, 'bad_size');
|
||||
}
|
||||
|
||||
// Validate it's actually a PDF (magic number check)
|
||||
$fh = @fopen($tmp, 'rb');
|
||||
if ($fh === false) {
|
||||
dbnToolsError('Kunne ikke lese filen.', 500, 'read_fail');
|
||||
}
|
||||
$head = (string)fread($fh, 5);
|
||||
fclose($fh);
|
||||
if (strncmp($head, '%PDF-', 5) !== 0) {
|
||||
dbnToolsError('Filen er ikke en gyldig PDF.', 415, 'not_pdf');
|
||||
}
|
||||
|
||||
try {
|
||||
$doc = CaseStore::registerUpload($userId, $name, $tmp, $size);
|
||||
CaseStore::caseEnqueueIngest((int)$doc['doc_id'], $userId);
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'doc_id' => $doc['doc_id'],
|
||||
'filename' => $doc['filename'],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
dbnToolsError($e->getMessage(), 400, 'upload_failed');
|
||||
}
|
||||
@@ -78,6 +78,7 @@ try {
|
||||
$input['deadlines']
|
||||
))), 0, 6) : [],
|
||||
'clarifications' => is_array($input['clarifications'] ?? null) ? $input['clarifications'] : [],
|
||||
'use_my_case' => !empty($input['use_my_case']),
|
||||
];
|
||||
|
||||
$forceDraft = !empty($input['force_draft']);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/StripeClient.php';
|
||||
require_once __DIR__ . '/../includes/FreeTier.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$user = dbnToolsAuthenticatedUser();
|
||||
$userId = (int)($user['user_id'] ?? 0);
|
||||
$email = (string)($user['email'] ?? '');
|
||||
if ($userId <= 0 || $email === '') {
|
||||
dbnToolsError('User session missing user_id or email.', 401, 'bad_session');
|
||||
}
|
||||
|
||||
$input = dbnToolsJsonInput(2000);
|
||||
$sku = (string)($input['sku'] ?? '');
|
||||
|
||||
$validSubscriptions = ['light', 'pro', 'pro_plus'];
|
||||
$validTopups = ['topup_s', 'topup_m', 'topup_l'];
|
||||
|
||||
if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
|
||||
dbnToolsError('Unknown SKU.', 400, 'unknown_sku');
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = new StripeClient();
|
||||
$customerId = $stripe->ensureCustomer($email, $userId);
|
||||
|
||||
$baseUrl = (dbnToolsIsHttps() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'tools.dobetternorge.no');
|
||||
$successUrl = $baseUrl . '/billing.php?status=success&session_id={CHECKOUT_SESSION_ID}';
|
||||
$cancelUrl = $baseUrl . '/pricing.php?status=canceled';
|
||||
|
||||
$isSub = in_array($sku, $validSubscriptions, true);
|
||||
|
||||
$params = [
|
||||
'mode' => $isSub ? 'subscription' : 'payment',
|
||||
'customer' => $customerId,
|
||||
'success_url' => $successUrl,
|
||||
'cancel_url' => $cancelUrl,
|
||||
'line_items' => [[
|
||||
'price' => StripeClient::priceId($sku),
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'metadata' => [
|
||||
'user_id' => (string)$userId,
|
||||
'sku' => $sku,
|
||||
],
|
||||
'allow_promotion_codes' => true,
|
||||
'billing_address_collection' => 'auto',
|
||||
'locale' => 'auto',
|
||||
'automatic_tax' => ['enabled' => false],
|
||||
];
|
||||
|
||||
if ($isSub) {
|
||||
$params['subscription_data'] = [
|
||||
'metadata' => ['user_id' => (string)$userId, 'tier' => $sku],
|
||||
];
|
||||
} else {
|
||||
$params['payment_intent_data'] = [
|
||||
'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
|
||||
];
|
||||
}
|
||||
|
||||
$session = $stripe->createCheckoutSession($params);
|
||||
$url = (string)($session['url'] ?? '');
|
||||
if ($url === '') {
|
||||
dbnToolsError('Stripe did not return a checkout URL.', 502, 'stripe_no_url');
|
||||
}
|
||||
|
||||
dbnToolsRespond(['ok' => true, 'url' => $url, 'session_id' => (string)($session['id'] ?? '')]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[stripe-checkout] ' . $e->getMessage());
|
||||
dbnToolsError('Could not start checkout: ' . $e->getMessage(), 500, 'stripe_failed');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/StripeClient.php';
|
||||
require_once __DIR__ . '/../includes/FreeTier.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$user = dbnToolsAuthenticatedUser();
|
||||
$userId = (int)($user['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
dbnToolsError('User session missing user_id.', 401, 'bad_session');
|
||||
}
|
||||
|
||||
$row = FreeTier::row($userId);
|
||||
$customerId = (string)($row['stripe_customer_id'] ?? '');
|
||||
if ($customerId === '') {
|
||||
// No Stripe customer yet — happens if user never checked out.
|
||||
dbnToolsError('No Stripe customer on record. Subscribe first.', 400, 'no_customer');
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = new StripeClient();
|
||||
$baseUrl = (dbnToolsIsHttps() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'tools.dobetternorge.no');
|
||||
$session = $stripe->createPortalSession($customerId, $baseUrl . '/billing.php');
|
||||
$url = (string)($session['url'] ?? '');
|
||||
if ($url === '') {
|
||||
dbnToolsError('Stripe did not return a portal URL.', 502, 'stripe_no_url');
|
||||
}
|
||||
dbnToolsRespond(['ok' => true, 'url' => $url]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[stripe-portal] ' . $e->getMessage());
|
||||
dbnToolsError('Could not open billing portal: ' . $e->getMessage(), 500, 'stripe_failed');
|
||||
}
|
||||
@@ -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