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']); } }