ba9cddf9a1
- 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>
332 lines
12 KiB
PHP
332 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Credit + tier system for users of tools.dobetternorge.no.
|
|
*
|
|
* Tables:
|
|
* user_tool_credits — balance (monthly, resets), bonus_balance (never expires), tier, Stripe links
|
|
* 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_plus tier bypasses balance checks (still subject to hourly cap).
|
|
*
|
|
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
|
|
* Only SSO sessions are subject to limits.
|
|
*/
|
|
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,
|
|
];
|
|
|
|
/** Monthly credit allowance per tier. pro_plus is "effectively unlimited" but hourly-capped. */
|
|
private const MONTHLY_ALLOWANCE = [
|
|
'free' => 30,
|
|
'light' => 120,
|
|
'pro' => 500,
|
|
'pro_plus' => 999999,
|
|
];
|
|
|
|
/** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
|
|
private const HOURLY_CAP = [
|
|
'free' => 10,
|
|
'light' => 15,
|
|
'pro' => 30,
|
|
'pro_plus' => 50,
|
|
];
|
|
|
|
/** Per-user case-storage quota in bytes. */
|
|
private const STORAGE_QUOTA = [
|
|
'free' => 0,
|
|
'light' => 104857600, // 100 MB
|
|
'pro' => 1073741824, // 1 GB
|
|
'pro_plus' => 10737418240, // 10 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;
|
|
}
|
|
|
|
public static function monthlyAllowance(string $tier): int
|
|
{
|
|
return self::MONTHLY_ALLOWANCE[$tier] ?? self::MONTHLY_ALLOWANCE['free'];
|
|
}
|
|
|
|
public static function hourlyCap(string $tier): int
|
|
{
|
|
return self::HOURLY_CAP[$tier] ?? self::HOURLY_CAP['free'];
|
|
}
|
|
|
|
public static function storageQuota(string $tier): int
|
|
{
|
|
return self::STORAGE_QUOTA[$tier] ?? 0;
|
|
}
|
|
|
|
/** 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';
|
|
}
|
|
|
|
/** 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.
|
|
$db->prepare(
|
|
"UPDATE user_tool_credits
|
|
SET balance = CASE tier
|
|
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
|
|
WHEN 'light' THEN " . self::MONTHLY_ALLOWANCE['light'] . "
|
|
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
|
|
WHEN 'pro_plus' THEN " . self::MONTHLY_ALLOWANCE['pro_plus'] . "
|
|
ELSE balance END,
|
|
last_reset = CURDATE()
|
|
WHERE user_id = ?
|
|
AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))"
|
|
)->execute([$userId]);
|
|
|
|
$stmt = $db->prepare('SELECT * FROM user_tool_credits WHERE user_id = ? LIMIT 1');
|
|
$stmt->execute([$userId]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
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
|
|
{
|
|
$db = dbnmDb();
|
|
$cost = self::cost($tool);
|
|
$row = self::row($userId);
|
|
|
|
if ($row === null) {
|
|
return [
|
|
'ok' => false, 'balance' => 0, 'bonus_balance' => 0,
|
|
'tier' => 'free', 'reason' => 'no_credits',
|
|
];
|
|
}
|
|
|
|
$balance = (int)$row['balance'];
|
|
$bonus = (int)$row['bonus_balance'];
|
|
$tier = (string)$row['tier'];
|
|
|
|
$base = [
|
|
'balance' => $balance,
|
|
'bonus_balance' => $bonus,
|
|
'tier' => $tier,
|
|
];
|
|
|
|
// Hourly rate limit (always applies, even to pro_plus)
|
|
$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'
|
|
);
|
|
$stmt->execute([$userId]);
|
|
$hourly = (int)$stmt->fetchColumn();
|
|
if ($hourly >= self::hourlyCap($tier)) {
|
|
return $base + ['ok' => false, 'reason' => 'rate_limit'];
|
|
}
|
|
|
|
// Free tool (cost=0) always passes credit check
|
|
if ($cost === 0) {
|
|
return $base + ['ok' => true];
|
|
}
|
|
|
|
// pro_plus bypasses credit check
|
|
if ($tier === 'pro_plus') {
|
|
return $base + ['ok' => true];
|
|
}
|
|
|
|
if (($balance + $bonus) < $cost) {
|
|
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`.
|
|
* pro_plus tier logs the call but does not deduct.
|
|
*
|
|
* 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);
|
|
$tier = $row['tier'] ?? 'free';
|
|
|
|
if ($cost > 0 && $tier !== 'pro_plus' && $row !== null) {
|
|
$balance = (int)$row['balance'];
|
|
$bonus = (int)$row['bonus_balance'];
|
|
|
|
$fromBalance = min($cost, $balance);
|
|
$fromBonus = $cost - $fromBalance;
|
|
|
|
$db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET balance = GREATEST(0, balance - ?),
|
|
bonus_balance = GREATEST(0, bonus_balance - ?)
|
|
WHERE user_id = ?'
|
|
)->execute([$fromBalance, $fromBonus, $userId]);
|
|
}
|
|
|
|
$db->prepare(
|
|
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
|
|
)->execute([$userId, $tool, $cost]);
|
|
|
|
$latest = self::row($userId);
|
|
return $latest ? ((int)$latest['balance'] + (int)$latest['bonus_balance']) : 0;
|
|
}
|
|
|
|
/** 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'],
|
|
'tier' => (string)$row['tier'],
|
|
'storage_used_bytes' => (int)($row['storage_used_bytes'] ?? 0),
|
|
'storage_quota_bytes' => self::storageQuota((string)$row['tier']),
|
|
'survey_completed_at' => $row['survey_completed_at'] ?? null,
|
|
'subscription_period_end' => $row['subscription_period_end'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return self::balance($userId);
|
|
}
|
|
$db = dbnmDb();
|
|
self::ensureRow($userId);
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* Set or upgrade a user's tier (called by Stripe subscription webhook).
|
|
* Refills monthly balance to the new tier's allowance.
|
|
*/
|
|
public static function setTier(
|
|
int $userId,
|
|
string $tier,
|
|
?string $stripeCustomerId,
|
|
?string $subscriptionId,
|
|
?string $periodEndIso
|
|
): void {
|
|
$db = dbnmDb();
|
|
self::ensureRow($userId);
|
|
$allowance = self::monthlyAllowance($tier);
|
|
$db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET tier = ?, balance = ?, allowance = ?,
|
|
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
|
subscription_id = ?,
|
|
subscription_period_end = ?,
|
|
last_reset = CURDATE()
|
|
WHERE user_id = ?'
|
|
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
$allowance = self::monthlyAllowance($tier);
|
|
$db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET balance = ?,
|
|
subscription_period_end = ?,
|
|
last_reset = CURDATE()
|
|
WHERE user_id = ?'
|
|
)->execute([$allowance, $periodEndIso, $userId]);
|
|
}
|
|
|
|
/**
|
|
* Revert a user to free tier (subscription canceled or fully ended).
|
|
* Preserves bonus_balance and case_documents (handled by 90-day cron).
|
|
*/
|
|
public static function clearTier(int $userId): void
|
|
{
|
|
$db = dbnmDb();
|
|
$db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET tier = ?, allowance = ?, subscription_id = NULL, subscription_period_end = NULL
|
|
WHERE user_id = ?'
|
|
)->execute(['free', self::monthlyAllowance('free'), $userId]);
|
|
}
|
|
|
|
/** Mark survey as completed so the bonus can only be claimed once per account. */
|
|
public static function markSurveyCompleted(int $userId): void
|
|
{
|
|
$db = dbnmDb();
|
|
self::ensureRow($userId);
|
|
$db->prepare('UPDATE user_tool_credits SET survey_completed_at = NOW() WHERE user_id = ?')
|
|
->execute([$userId]);
|
|
}
|
|
|
|
public static function hasCompletedSurvey(int $userId): bool
|
|
{
|
|
$row = self::row($userId);
|
|
return !empty($row['survey_completed_at']);
|
|
}
|
|
|
|
/** Create the user_tool_credits row if missing (idempotent). */
|
|
public static function ensureRow(int $userId): void
|
|
{
|
|
$db = dbnmDb();
|
|
$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']);
|
|
}
|
|
}
|