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>
This commit is contained in:
2026-05-24 13:42:27 +02:00
parent bffc714541
commit b21bfb2f1d
30 changed files with 1171 additions and 586 deletions
+224 -139
View File
@@ -1,102 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/PricingCatalog.php';
/**
* Credit + tier system for users of tools.dobetternorge.no.
* Credit + tier system for SSO users of tools.dobetternorge.no.
*
* Three tiers: free, plus, pro (NOK pricing, see pricing.php).
*
* Tables:
* user_tool_credits — balance (monthly, resets), bonus_balance (never expires), tier,
* Stripe links, trial_started_at / trial_expires_at / trial_downgraded_at
* user_tool_usage_log — every tool call with credits_used
* user_subscriptions — Stripe subscription ledger
*
* Effective balance = balance + bonus_balance.
* Spend order: deduct from balance first, overflow to bonus_balance.
* Pro tier has the largest monthly allowance (still subject to hourly cap).
*
* Trial is Stripe-driven: when subscription.status='trialing', tier='plus' and
* trial_expires_at mirrors Stripe's trial_end. No homegrown trial cron — the
* Stripe webhook flips tier='free' on subscription.deleted.
*
* CaveauAI client sessions bypass all credit checks.
* Only SSO sessions are subject to limits.
* balance = monthly credits, reset/refilled by tier.
* bonus_balance = prepaid/top-up credits, never expires.
* Spend order: monthly first, prepaid second.
*/
final class FreeTier
{
private const COSTS = [
'corpus-search' => 0,
'search' => 0,
'ask' => 1,
'extract' => 1,
'timeline' => 2,
'redact' => 2,
'barnevernet' => 3,
'advocate' => 3,
'deep-research' => 5,
'transcribe' => 2,
'discrepancy' => 4,
'korrespond' => 3,
'translate' => 2,
];
/** Monthly credit allowance per tier. */
private const MONTHLY_ALLOWANCE = [
'free' => 30,
'plus' => 250,
'pro' => 1000,
];
/** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
private const HOURLY_CAP = [
'free' => 10,
'plus' => 20,
'pro' => 40,
];
/** Per-user case-storage quota in bytes. */
private const STORAGE_QUOTA = [
'free' => 0,
'plus' => 524288000, // 500 MB
'pro' => 5368709120, // 5 GB
];
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
public static function cost(string $tool): int
{
return self::COSTS[$tool] ?? 1;
return PricingCatalog::toolCost($tool);
}
public static function monthlyAllowance(string $tier): int
{
return self::MONTHLY_ALLOWANCE[$tier] ?? self::MONTHLY_ALLOWANCE['free'];
return PricingCatalog::monthlyAllowance($tier);
}
public static function hourlyCap(string $tier): int
{
return self::HOURLY_CAP[$tier] ?? self::HOURLY_CAP['free'];
return PricingCatalog::hourlyCap($tier);
}
public static function storageQuota(string $tier): int
{
return self::STORAGE_QUOTA[$tier] ?? 0;
return PricingCatalog::storageQuota($tier);
}
/** Fetch a user's tier (defaults to 'free' if no row). */
public static function tier(int $userId): string
{
$row = self::row($userId);
return $row['tier'] ?? 'free';
}
/** Tiers that have access to "My Case" features (upload, save analyses, use case context). */
public static function isPaidTier(string $tier): bool
{
return in_array($tier, ['plus', 'pro'], true);
return PricingCatalog::isPaidTier($tier);
}
/** True when the user is currently in a Stripe trial (tier='plus', trial_expires_at in future). */
public static function isTrialActive(int $userId): bool
{
$row = self::row($userId);
@@ -104,13 +50,9 @@ final class FreeTier
return false;
}
$expires = $row['trial_expires_at'] ?? null;
if (!$expires) {
return false;
}
return strtotime((string)$expires) > time();
return $expires ? strtotime((string)$expires) > time() : false;
}
/** Days remaining in the active trial (0 if no trial / expired). */
public static function trialDaysRemaining(int $userId): int
{
if (!self::isTrialActive($userId)) {
@@ -121,18 +63,25 @@ final class FreeTier
return max(0, (int)ceil(($expires - time()) / 86400));
}
/** Fetch the full credits row, applying lazy monthly reset. */
public static function row(int $userId): ?array
{
$db = dbnmDb();
// Auto-refill monthly balance based on tier-specific allowance, only if a new calendar month has begun.
$freeAllowance = self::monthlyAllowance('free');
$plusAllowance = self::monthlyAllowance('plus');
$proAllowance = self::monthlyAllowance('pro');
$db->prepare(
"UPDATE user_tool_credits
SET balance = CASE tier
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
WHEN 'plus' THEN " . self::MONTHLY_ALLOWANCE['plus'] . "
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
WHEN 'free' THEN {$freeAllowance}
WHEN 'plus' THEN {$plusAllowance}
WHEN 'pro' THEN {$proAllowance}
ELSE balance END,
allowance = CASE tier
WHEN 'free' THEN {$freeAllowance}
WHEN 'plus' THEN {$plusAllowance}
WHEN 'pro' THEN {$proAllowance}
ELSE allowance END,
last_reset = CURDATE()
WHERE user_id = ?
AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))"
@@ -144,38 +93,39 @@ final class FreeTier
return is_array($row) ? $row : null;
}
/**
* Check whether the user may proceed with a tool call.
*
* Returns:
* ['ok' => true, 'balance' => int, 'bonus_balance' => int, 'tier' => string]
* ['ok' => false, 'balance' => int, 'bonus_balance' => int, 'tier' => string,
* 'reason' => 'no_credits'|'rate_limit']
*/
public static function check(int $userId, string $tool): array
{
return self::checkAmount($userId, $tool, self::cost($tool));
}
public static function checkAmount(int $userId, string $tool, int $credits): array
{
$db = dbnmDb();
$cost = self::cost($tool);
$row = self::row($userId);
$credits = max(0, $credits);
if ($row === null) {
return [
'ok' => false, 'balance' => 0, 'bonus_balance' => 0,
'tier' => 'free', 'reason' => 'no_credits',
'ok' => false,
'balance' => 0,
'bonus_balance' => 0,
'tier' => 'free',
'reason' => 'no_credits',
'cost' => $credits,
];
}
$balance = (int)$row['balance'];
$bonus = (int)$row['bonus_balance'];
$tier = (string)$row['tier'];
$bonus = (int)$row['bonus_balance'];
$tier = (string)$row['tier'];
$base = [
'balance' => $balance,
'bonus_balance' => $bonus,
'tier' => $tier,
'cost' => $credits,
];
// Hourly rate limit (always applies)
$stmt = $db->prepare(
'SELECT COUNT(*) FROM user_tool_usage_log
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
@@ -186,67 +136,85 @@ final class FreeTier
return $base + ['ok' => false, 'reason' => 'rate_limit'];
}
// Free tool (cost=0) always passes credit check
if ($cost === 0) {
if ($credits === 0) {
return $base + ['ok' => true];
}
if (($balance + $bonus) < $cost) {
if (($balance + $bonus) < $credits) {
return $base + ['ok' => false, 'reason' => 'no_credits'];
}
return $base + ['ok' => true];
}
/**
* Deduct credits for a completed tool call and log the usage.
* Spends from `balance` first, then `bonus_balance`.
*
* Returns the new effective balance (balance + bonus_balance).
*/
public static function deduct(int $userId, string $tool): int
{
$db = dbnmDb();
$cost = self::cost($tool);
$row = self::row($userId);
return self::deductAmount($userId, $tool, self::cost($tool));
}
if ($cost > 0 && $row !== null) {
$balance = (int)$row['balance'];
$bonus = (int)$row['bonus_balance'];
public static function deductAmount(int $userId, string $tool, int $credits, array $metadata = []): int
{
$db = dbnmDb();
$credits = max(0, $credits);
$row = self::row($userId);
$beforeMonthly = $row ? (int)$row['balance'] : 0;
$beforePrepaid = $row ? (int)$row['bonus_balance'] : 0;
$fromMonthly = 0;
$fromPrepaid = 0;
$fromBalance = min($cost, $balance);
$fromBonus = $cost - $fromBalance;
if ($credits > 0 && $row !== null) {
$fromMonthly = min($credits, $beforeMonthly);
$fromPrepaid = $credits - $fromMonthly;
$db->prepare(
$stmt = $db->prepare(
'UPDATE user_tool_credits
SET balance = GREATEST(0, balance - ?),
bonus_balance = GREATEST(0, bonus_balance - ?)
WHERE user_id = ?'
)->execute([$fromBalance, $fromBonus, $userId]);
WHERE user_id = ?
AND (balance + bonus_balance) >= ?'
);
$stmt->execute([$fromMonthly, $fromPrepaid, $userId, $credits]);
if ($stmt->rowCount() < 1) {
$check = self::checkAmount($userId, $tool, $credits);
if (empty($check['ok'])) {
throw new RuntimeException('Insufficient credits while settling tool charge.');
}
}
}
$db->prepare(
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
)->execute([$userId, $tool, $cost]);
)->execute([$userId, $tool, $credits]);
$latest = self::row($userId);
return $latest ? ((int)$latest['balance'] + (int)$latest['bonus_balance']) : 0;
$afterMonthly = $latest ? (int)$latest['balance'] : 0;
$afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0;
self::recordLedger($userId, 'tool_charge', $tool, -$credits, [
'from_monthly' => $fromMonthly,
'from_prepaid' => $fromPrepaid,
'before_monthly' => $beforeMonthly,
'before_prepaid' => $beforePrepaid,
'after_monthly' => $afterMonthly,
'after_prepaid' => $afterPrepaid,
] + $metadata);
return $afterMonthly + $afterPrepaid;
}
/** Effective balance (monthly + bonus). */
public static function balance(int $userId): int
{
$row = self::row($userId);
return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0;
}
/** Detailed balance breakdown for UI rendering. */
public static function balanceDetail(int $userId): array
{
$row = self::row($userId);
if (!$row) {
return ['balance' => 0, 'bonus_balance' => 0, 'tier' => 'free'];
}
return [
'balance' => (int)$row['balance'],
'bonus_balance' => (int)$row['bonus_balance'],
@@ -262,10 +230,6 @@ final class FreeTier
];
}
/**
* Award one-time bonus credits (survey reward, Stripe topup, manual grant).
* Source is logged via user_tool_usage_log with a negative credits_used value.
*/
public static function awardBonus(int $userId, int $credits, string $source): int
{
if ($credits <= 0) {
@@ -273,20 +237,29 @@ final class FreeTier
}
$db = dbnmDb();
self::ensureRow($userId);
$row = self::row($userId);
$beforeMonthly = $row ? (int)$row['balance'] : 0;
$beforePrepaid = $row ? (int)$row['bonus_balance'] : 0;
$db->prepare('UPDATE user_tool_credits SET bonus_balance = bonus_balance + ? WHERE user_id = ?')
->execute([$credits, $userId]);
$db->prepare('INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)')
->execute([$userId, 'bonus:' . substr($source, 0, 40), -$credits]);
return self::balance($userId);
$latest = self::row($userId);
$afterMonthly = $latest ? (int)$latest['balance'] : 0;
$afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0;
self::recordLedger($userId, 'credit_grant', 'bonus:' . substr($source, 0, 40), $credits, [
'source' => $source,
'before_monthly' => $beforeMonthly,
'before_prepaid' => $beforePrepaid,
'after_monthly' => $afterMonthly,
'after_prepaid' => $afterPrepaid,
]);
return $afterMonthly + $afterPrepaid;
}
/**
* Set or upgrade a user's tier (called by Stripe subscription webhook).
* Refills monthly balance to the new tier's allowance.
*
* When $trialEndIso is non-null, also writes trial_started_at (preserving original on updates)
* and trial_expires_at — used when subscription.status='trialing'.
*/
public static function setTier(
int $userId,
string $tier,
@@ -325,12 +298,16 @@ final class FreeTier
WHERE user_id = ?'
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
}
self::recordLedger($userId, 'subscription_refill', 'subscription:' . $tier, $allowance, [
'tier' => $tier,
'subscription_id' => $subscriptionId,
'stripe_customer_id' => $stripeCustomerId,
'period_end' => $periodEndIso,
'trial_end' => $trialEndIso,
]);
}
/**
* Refill monthly balance at subscription renewal (invoice.paid).
* Does not touch bonus_balance.
*/
public static function refillForRenewal(int $userId, string $tier, ?string $periodEndIso): void
{
$db = dbnmDb();
@@ -338,20 +315,22 @@ final class FreeTier
$db->prepare(
'UPDATE user_tool_credits
SET balance = ?,
allowance = ?,
subscription_period_end = ?,
last_reset = CURDATE()
WHERE user_id = ?'
)->execute([$allowance, $periodEndIso, $userId]);
)->execute([$allowance, $allowance, $periodEndIso, $userId]);
self::recordLedger($userId, 'subscription_refill', 'invoice:' . $tier, $allowance, [
'tier' => $tier,
'period_end' => $periodEndIso,
]);
}
/**
* Revert a user to free tier (subscription canceled, trial ended without conversion).
* Preserves bonus_balance and case_documents (handled by 60-day cron).
* Stamps trial_downgraded_at if a trial was active.
*/
public static function clearTier(int $userId): void
{
$db = dbnmDb();
$allowance = self::monthlyAllowance('free');
$db->prepare(
"UPDATE user_tool_credits
SET tier = 'free',
@@ -366,10 +345,11 @@ final class FreeTier
END,
trial_expires_at = NULL
WHERE user_id = ?"
)->execute([self::monthlyAllowance('free'), self::monthlyAllowance('free'), $userId]);
)->execute([$allowance, $allowance, $userId]);
self::recordLedger($userId, 'tier_change', 'subscription:clear', 0, ['tier' => 'free']);
}
/** Mark survey as completed so the bonus can only be claimed once per account. */
public static function markSurveyCompleted(int $userId): void
{
$db = dbnmDb();
@@ -384,13 +364,118 @@ final class FreeTier
return !empty($row['survey_completed_at']);
}
/** Create the user_tool_credits row if missing (idempotent). */
public static function ensureRow(int $userId): void
{
$db = dbnmDb();
$allowance = self::monthlyAllowance('free');
$db->prepare(
'INSERT IGNORE INTO user_tool_credits (user_id, balance, allowance, tier, last_reset, created_at)
VALUES (?, ?, ?, ?, CURDATE(), NOW())'
)->execute([$userId, self::monthlyAllowance('free'), self::monthlyAllowance('free'), 'free']);
)->execute([$userId, $allowance, $allowance, 'free']);
}
public static function transcribeCreditsForSeconds(float $seconds): int
{
return PricingCatalog::transcribeCreditsForSeconds($seconds);
}
public static function estimateTranscribeCreditsFromBytes(int $bytes): int
{
return PricingCatalog::estimateTranscribeCreditsFromBytes($bytes);
}
public static function estimateTranscribeCreditsFromFile(string $filename, int $bytes): int
{
return PricingCatalog::estimateTranscribeCreditsFromFile($filename, $bytes);
}
public static function createReservation(int $userId, string $tool, int $credits, array $metadata = []): int
{
try {
$db = dbnmDb();
$db->prepare(
'INSERT INTO user_tool_credit_reservations
(user_id, tool, reserved_credits, status, metadata_json, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 2 HOUR))'
)->execute([
$userId,
substr($tool, 0, 40),
max(0, $credits),
'reserved',
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
return (int)$db->lastInsertId();
} catch (Throwable $e) {
return 0;
}
}
public static function settleReservation(int $reservationId, int $credits, string $provider, float $durationSeconds, array $metadata = []): void
{
if ($reservationId <= 0) {
return;
}
try {
$db = dbnmDb();
$db->prepare(
"UPDATE user_tool_credit_reservations
SET status = 'settled',
settled_credits = ?,
provider = ?,
duration_seconds = ?,
metadata_json = ?,
settled_at = NOW()
WHERE id = ? AND status = 'reserved'"
)->execute([
max(0, $credits),
substr($provider, 0, 40),
round($durationSeconds, 2),
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$reservationId,
]);
} catch (Throwable $e) {
// Non-fatal audit path.
}
}
public static function releaseReservation(int $reservationId, array $metadata = []): void
{
if ($reservationId <= 0) {
return;
}
try {
$db = dbnmDb();
$db->prepare(
"UPDATE user_tool_credit_reservations
SET status = 'released',
metadata_json = ?
WHERE id = ? AND status = 'reserved'"
)->execute([
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$reservationId,
]);
} catch (Throwable $e) {
// Non-fatal audit path.
}
}
private static function recordLedger(int $userId, string $eventType, string $source, int $creditsDelta, array $metadata = []): void
{
try {
$db = dbnmDb();
$db->prepare(
'INSERT INTO user_tool_credit_ledger
(user_id, event_type, source, credits_delta, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, NOW())'
)->execute([
$userId,
substr($eventType, 0, 40),
substr($source, 0, 100),
$creditsDelta,
json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
} catch (Throwable $e) {
// Ledger is additive and should not block core credit behavior before migration is applied.
}
}
}