feat: free-tier credit system + Syttende Mai access for Google users

- 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>
This commit is contained in:
2026-05-16 21:05:08 +02:00
parent 568314c554
commit 8b77acb828
15 changed files with 464 additions and 2 deletions
+126
View File
@@ -0,0 +1,126 @@
<?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'];
}
}
+97
View File
@@ -385,6 +385,103 @@ function dbnToolsRagDb(): PDO
}
}
/**
* PDO connection to dobetternorge_maindb (user accounts + credits).
* Credentials come from DBNM_DB_* env vars.
*/
function dbnmDb(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$host = dbnToolsEnv('DBNM_DB_HOST', 'localhost');
$name = dbnToolsEnv('DBNM_DB_NAME', 'dobetternorge_maindb');
$user = dbnToolsEnv('DBNM_DB_USER', 'root');
$pass = dbnToolsEnv('DBNM_DB_PASS', '');
try {
$pdo = new PDO(
"mysql:host={$host};dbname={$name};charset=utf8mb4",
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
throw new DbnToolsHttpException('Credit database is not reachable.', 503, 'credit_db_unavailable');
}
return $pdo;
}
/**
* True when the current session is a free-tier SSO user (Google login).
* False for CaveauAI client sessions (always unlimited).
*/
function dbnToolsIsFreeTier(): bool
{
return !empty($_SESSION['dbn_tools_authenticated'])
&& !empty($_SESSION['dbn_tools_sso_uid'])
&& ($_SESSION['dbn_tools_tier'] ?? '') === 'free';
}
/**
* Enforce free-tier credit gate before a tool call.
* Exits with JSON 402/429 if the user is over limit or out of credits.
* No-op for CaveauAI sessions.
*
* @return int The user_id for use in dbnToolsFreeTierDeduct()
*/
function dbnToolsFreeTierCheck(string $tool): int
{
if (!dbnToolsIsFreeTier()) {
return 0;
}
require_once __DIR__ . '/FreeTier.php';
$uid = (int)$_SESSION['dbn_tools_sso_uid'];
$result = FreeTier::check($uid, $tool);
if (!$result['ok']) {
$isRateLimit = ($result['reason'] ?? '') === 'rate_limit';
http_response_code($isRateLimit ? 429 : 402);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'ok' => false,
'error' => ['code' => $result['reason'], 'message' => $isRateLimit
? 'Rate limit reached — you can make up to 10 requests per hour on the free tier.'
: 'No credits remaining. Your 10 free credits reset on the 1st of next month.',
],
'balance' => $result['balance'],
], JSON_UNESCAPED_UNICODE);
exit;
}
return $uid;
}
/**
* Deduct credits after a successful tool call.
* The $uid returned from dbnToolsFreeTierCheck() must be passed in.
* No-op when uid === 0 (non-free-tier session).
*
* @return int Remaining balance (for response header)
*/
function dbnToolsFreeTierDeduct(int $uid, string $tool): int
{
if ($uid === 0) {
return -1;
}
require_once __DIR__ . '/FreeTier.php';
return FreeTier::deduct($uid, $tool);
}
function dbnToolsClientSlug(): string
{
return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter';
+19
View File
@@ -16,6 +16,13 @@ $toolTitle = $toolMeta['label'] ?? ($toolTitle ?? dbnToolsT('suite_title', $uiLa
$toolKind = $toolMeta['sub'] ?? ($toolKind ?? '');
$toolBadge = $toolMeta['badge'] ?? ($toolBadge ?? '');
$langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/';
// Credit balance for free-tier users
$layoutFreeTierBalance = -1;
if (dbnToolsIsFreeTier()) {
require_once __DIR__ . '/FreeTier.php';
$layoutFreeTierBalance = FreeTier::balance((int)$_SESSION['dbn_tools_sso_uid']);
}
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
@@ -23,13 +30,25 @@ $langPath = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/';
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($toolTitle) ?> - Do Better Norge</title>
<?php if ($layoutFreeTierBalance >= 0): ?>
<meta name="dbn-credits" content="<?= $layoutFreeTierBalance ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/css/tools.css">
</head>
<body data-authenticated="true" data-active-tool="<?= htmlspecialchars($toolName) ?>">
<script>
window.DBN_TOOLS_AUTHENTICATED = true;
window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
<?php if ($layoutFreeTierBalance >= 0): ?>
window.DBN_FREE_TIER_BALANCE = <?= $layoutFreeTierBalance ?>;
<?php endif; ?>
</script>
<?php if (date('m-d') === '05-17'): ?>
<div id="syttendeMaiBanner" class="syttende-mai-banner" role="banner">
<span>🇳🇴 Gratulerer med dagen! Free access today — explore our AI legal tools for Norwegian family law.</span>
<button onclick="this.parentElement.remove()" aria-label="Dismiss" class="syttende-mai-close">✕</button>
</div>
<?php endif; ?>
<main id="appShell" class="app-shell">
<header class="topbar">
<div>