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 'discrepancy' => 4, // 2 docs × 4 extraction steps + cross-ref + synthesis ]; /** 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']; } }