Files
daveadmin b21bfb2f1d Add NOK pricing catalog, credit ledger, success-based charging, and tier-gated model routing
- PricingCatalog.php: single source of truth for plans (free/plus/pro), top-ups,
  Stripe price env keys, tool costs (0–6 credits), STT variable billing, feature limits
- FreeTier.php: monthly-first credit deduction, ledger (user_tool_credit_ledger),
  STT reservation/settle/release, monthly reset, trial logic
- StripeClient.php: canonical SKUs (plus/pro/topup_100/300/1000), legacy aliases kept
- stripe-checkout.php: subscription vs payment mode, trial gating, catalog metadata
- stripe-webhook.php: idempotent via stripe_events, handles subscription lifecycle +
  invoice.paid renewal + one-time topup credit grants
- All API tools: success-based credit deduction (check before, charge after)
- transcribe.php: file-size heuristic reservation, settle from actual provider duration
- ask.php + LegalTools.php: ToolModels engine resolution — Pro gets gpt-4o
- KorrespondAgent.php + korrespond.php: tier-gated draft deployment —
  Free/Plus gets gpt-4o-mini, Pro gets gpt-4o
- pricing.php: NOK-only, plan cards, top-up packs, Organisation contact card,
  tool cost table, separate monthly/prepaid balance display
- 003_pricing_credit_catalog.sql: ledger and STT reservation tables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:42:27 +02:00

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 = 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.
}