8b77acb828
- FreeTier.php: credit check/deduct/reset engine with hourly rate limit - bootstrap.php: dbnmDb() singleton, dbnToolsIsFreeTier(), credit gate helpers - index.php: store tier=free|approved in session from SSO JWT - All 7 API endpoints: credit gate (402/429) + X-Credits-Remaining header - layout.php: credit meta tag, JS balance var, Syttende Mai banner (05-17 only) - tools.js: credit badge in topbar, 402 modal, 429 toast, dbnUpdateCredits() - barnevernet.js + deep-research.js: wire 402/429 handling for NDJSON streams - tools.css: styles for credit badge, no-credits modal, rate-limit toast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
127 lines
4.1 KiB
PHP
127 lines
4.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Credit system for free-tier (Google-authenticated) users of tools.dobetternorge.no.
|
|
*
|
|
* Credits are stored in dobetternorge_maindb.user_tool_credits.
|
|
* Usage is logged in user_tool_usage_log.
|
|
*
|
|
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
|
|
* Only SSO sessions with tier='free' are subject to limits.
|
|
*/
|
|
final class FreeTier
|
|
{
|
|
private const HOURLY_LIMIT = 10;
|
|
|
|
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, // flat rate; actual duration unknown upfront
|
|
];
|
|
|
|
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
|
|
public static function cost(string $tool): int
|
|
{
|
|
return self::COSTS[$tool] ?? 1;
|
|
}
|
|
|
|
/**
|
|
* Check whether the user may proceed with a tool call.
|
|
* Handles monthly reset automatically.
|
|
*
|
|
* Returns ['ok' => true, 'balance' => int]
|
|
* or ['ok' => false, 'balance' => int, 'reason' => 'no_credits'|'rate_limit']
|
|
*/
|
|
public static function check(int $userId, string $tool): array
|
|
{
|
|
$db = dbnmDb();
|
|
$cost = self::cost($tool);
|
|
|
|
// Auto-reset balance if a new month has begun since last reset
|
|
$db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET balance = allowance, last_reset = CURDATE()
|
|
WHERE user_id = ?
|
|
AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))'
|
|
)->execute([$userId]);
|
|
|
|
$row = $db->prepare(
|
|
'SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1'
|
|
);
|
|
$row->execute([$userId]);
|
|
$credits = $row->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if ($credits === false) {
|
|
// No credits row — treat as 0 balance (shouldn't happen after ensureFreeTierCredits)
|
|
return ['ok' => false, 'balance' => 0, 'reason' => 'no_credits'];
|
|
}
|
|
|
|
$balance = (int)$credits['balance'];
|
|
|
|
// Free tools always pass
|
|
if ($cost === 0) {
|
|
return ['ok' => true, 'balance' => $balance];
|
|
}
|
|
|
|
if ($balance < $cost) {
|
|
return ['ok' => false, 'balance' => $balance, 'reason' => 'no_credits'];
|
|
}
|
|
|
|
// Hourly rate limit check (counts any tool that costs > 0)
|
|
$hourlyCount = $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'
|
|
);
|
|
$hourlyCount->execute([$userId]);
|
|
if ((int)$hourlyCount->fetchColumn() >= self::HOURLY_LIMIT) {
|
|
return ['ok' => false, 'balance' => $balance, 'reason' => 'rate_limit'];
|
|
}
|
|
|
|
return ['ok' => true, 'balance' => $balance];
|
|
}
|
|
|
|
/**
|
|
* Deduct credits for a completed tool call and log the usage.
|
|
* Safe to call even when cost is 0 (logs the call but deducts nothing).
|
|
*/
|
|
public static function deduct(int $userId, string $tool): int
|
|
{
|
|
$db = dbnmDb();
|
|
$cost = self::cost($tool);
|
|
|
|
if ($cost > 0) {
|
|
$db->prepare(
|
|
'UPDATE user_tool_credits
|
|
SET balance = GREATEST(0, balance - ?)
|
|
WHERE user_id = ?'
|
|
)->execute([$cost, $userId]);
|
|
}
|
|
|
|
$db->prepare(
|
|
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
|
|
)->execute([$userId, $tool, $cost]);
|
|
|
|
$row = $db->prepare('SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1');
|
|
$row->execute([$userId]);
|
|
$r = $row->fetch(PDO::FETCH_ASSOC);
|
|
return $r ? (int)$r['balance'] : 0;
|
|
}
|
|
|
|
/**
|
|
* Current balance for a user (after any pending monthly reset).
|
|
*/
|
|
public static function balance(int $userId): int
|
|
{
|
|
$result = self::check($userId, 'corpus-search'); // cost=0, triggers reset if needed
|
|
return $result['balance'];
|
|
}
|
|
}
|