time() : false; } public static function trialDaysRemaining(int $userId): int { if (!self::isTrialActive($userId)) { return 0; } $row = self::row($userId); $expires = strtotime((string)$row['trial_expires_at']); return max(0, (int)ceil(($expires - time()) / 86400)); } public static function row(int $userId): ?array { $db = dbnmDb(); $freeAllowance = self::monthlyAllowance('free'); $plusAllowance = self::monthlyAllowance('plus'); $proAllowance = self::monthlyAllowance('pro'); $db->prepare( "UPDATE user_tool_credits SET balance = CASE tier WHEN 'free' THEN {$freeAllowance} WHEN 'plus' THEN {$plusAllowance} WHEN 'pro' THEN {$proAllowance} ELSE balance END, allowance = CASE tier WHEN 'free' THEN {$freeAllowance} WHEN 'plus' THEN {$plusAllowance} WHEN 'pro' THEN {$proAllowance} ELSE allowance 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; } public static function check(int $userId, string $tool): array { return self::checkAmount($userId, $tool, self::cost($tool)); } public static function checkAmount(int $userId, string $tool, int $credits): array { $db = dbnmDb(); $row = self::row($userId); $credits = max(0, $credits); if ($row === null) { return [ 'ok' => false, 'balance' => 0, 'bonus_balance' => 0, 'tier' => 'free', 'reason' => 'no_credits', 'cost' => $credits, ]; } $balance = (int)$row['balance']; $bonus = (int)$row['bonus_balance']; $tier = (string)$row['tier']; $base = [ 'balance' => $balance, 'bonus_balance' => $bonus, 'tier' => $tier, 'cost' => $credits, ]; $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']; } if ($credits === 0) { return $base + ['ok' => true]; } if (($balance + $bonus) < $credits) { return $base + ['ok' => false, 'reason' => 'no_credits']; } return $base + ['ok' => true]; } public static function deduct(int $userId, string $tool): int { return self::deductAmount($userId, $tool, self::cost($tool)); } public static function deductAmount(int $userId, string $tool, int $credits, array $metadata = []): int { $db = dbnmDb(); $credits = max(0, $credits); $row = self::row($userId); $beforeMonthly = $row ? (int)$row['balance'] : 0; $beforePrepaid = $row ? (int)$row['bonus_balance'] : 0; $fromMonthly = 0; $fromPrepaid = 0; if ($credits > 0 && $row !== null) { $fromMonthly = min($credits, $beforeMonthly); $fromPrepaid = $credits - $fromMonthly; $stmt = $db->prepare( 'UPDATE user_tool_credits SET balance = GREATEST(0, balance - ?), bonus_balance = GREATEST(0, bonus_balance - ?) WHERE user_id = ? AND (balance + bonus_balance) >= ?' ); $stmt->execute([$fromMonthly, $fromPrepaid, $userId, $credits]); if ($stmt->rowCount() < 1) { $check = self::checkAmount($userId, $tool, $credits); if (empty($check['ok'])) { throw new RuntimeException('Insufficient credits while settling tool charge.'); } } } $db->prepare( 'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)' )->execute([$userId, $tool, $credits]); $latest = self::row($userId); $afterMonthly = $latest ? (int)$latest['balance'] : 0; $afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0; self::recordLedger($userId, 'tool_charge', $tool, -$credits, [ 'from_monthly' => $fromMonthly, 'from_prepaid' => $fromPrepaid, 'before_monthly' => $beforeMonthly, 'before_prepaid' => $beforePrepaid, 'after_monthly' => $afterMonthly, 'after_prepaid' => $afterPrepaid, ] + $metadata); return $afterMonthly + $afterPrepaid; } public static function balance(int $userId): int { $row = self::row($userId); return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0; } 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, 'trial_started_at' => $row['trial_started_at'] ?? null, 'trial_expires_at' => $row['trial_expires_at'] ?? null, 'trial_active' => self::isTrialActive($userId), 'trial_days_remaining' => self::trialDaysRemaining($userId), ]; } public static function awardBonus(int $userId, int $credits, string $source): int { if ($credits <= 0) { return self::balance($userId); } $db = dbnmDb(); self::ensureRow($userId); $row = self::row($userId); $beforeMonthly = $row ? (int)$row['balance'] : 0; $beforePrepaid = $row ? (int)$row['bonus_balance'] : 0; $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]); $latest = self::row($userId); $afterMonthly = $latest ? (int)$latest['balance'] : 0; $afterPrepaid = $latest ? (int)$latest['bonus_balance'] : 0; self::recordLedger($userId, 'credit_grant', 'bonus:' . substr($source, 0, 40), $credits, [ 'source' => $source, 'before_monthly' => $beforeMonthly, 'before_prepaid' => $beforePrepaid, 'after_monthly' => $afterMonthly, 'after_prepaid' => $afterPrepaid, ]); return $afterMonthly + $afterPrepaid; } public static function setTier( int $userId, string $tier, ?string $stripeCustomerId, ?string $subscriptionId, ?string $periodEndIso, ?string $trialEndIso = null ): void { $db = dbnmDb(); self::ensureRow($userId); $allowance = self::monthlyAllowance($tier); if ($trialEndIso !== null) { $db->prepare( 'UPDATE user_tool_credits SET tier = ?, balance = ?, allowance = ?, stripe_customer_id = COALESCE(?, stripe_customer_id), subscription_id = ?, subscription_period_end = ?, trial_started_at = COALESCE(trial_started_at, NOW()), trial_expires_at = ?, trial_downgraded_at = NULL, last_reset = CURDATE() WHERE user_id = ?' )->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $trialEndIso, $userId]); } else { $db->prepare( 'UPDATE user_tool_credits SET tier = ?, balance = ?, allowance = ?, stripe_customer_id = COALESCE(?, stripe_customer_id), subscription_id = ?, subscription_period_end = ?, trial_expires_at = NULL, trial_downgraded_at = NULL, last_reset = CURDATE() WHERE user_id = ?' )->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]); } self::recordLedger($userId, 'subscription_refill', 'subscription:' . $tier, $allowance, [ 'tier' => $tier, 'subscription_id' => $subscriptionId, 'stripe_customer_id' => $stripeCustomerId, 'period_end' => $periodEndIso, 'trial_end' => $trialEndIso, ]); } 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 = ?, allowance = ?, subscription_period_end = ?, last_reset = CURDATE() WHERE user_id = ?' )->execute([$allowance, $allowance, $periodEndIso, $userId]); self::recordLedger($userId, 'subscription_refill', 'invoice:' . $tier, $allowance, [ 'tier' => $tier, 'period_end' => $periodEndIso, ]); } public static function clearTier(int $userId): void { $db = dbnmDb(); $allowance = self::monthlyAllowance('free'); $db->prepare( "UPDATE user_tool_credits SET tier = 'free', allowance = ?, balance = ?, subscription_id = NULL, subscription_period_end = NULL, trial_downgraded_at = CASE WHEN trial_expires_at IS NOT NULL AND trial_downgraded_at IS NULL THEN NOW() ELSE trial_downgraded_at END, trial_expires_at = NULL WHERE user_id = ?" )->execute([$allowance, $allowance, $userId]); self::recordLedger($userId, 'tier_change', 'subscription:clear', 0, ['tier' => 'free']); } 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']); } public static function ensureRow(int $userId): void { $db = dbnmDb(); $allowance = self::monthlyAllowance('free'); $db->prepare( 'INSERT IGNORE INTO user_tool_credits (user_id, balance, allowance, tier, last_reset, created_at) VALUES (?, ?, ?, ?, CURDATE(), NOW())' )->execute([$userId, $allowance, $allowance, 'free']); } public static function transcribeCreditsForSeconds(float $seconds): int { return PricingCatalog::transcribeCreditsForSeconds($seconds); } public static function estimateTranscribeCreditsFromBytes(int $bytes): int { return PricingCatalog::estimateTranscribeCreditsFromBytes($bytes); } public static function estimateTranscribeCreditsFromFile(string $filename, int $bytes): int { return PricingCatalog::estimateTranscribeCreditsFromFile($filename, $bytes); } public static function createReservation(int $userId, string $tool, int $credits, array $metadata = []): int { try { $db = dbnmDb(); $db->prepare( 'INSERT INTO user_tool_credit_reservations (user_id, tool, reserved_credits, status, metadata_json, created_at, expires_at) VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 2 HOUR))' )->execute([ $userId, substr($tool, 0, 40), max(0, $credits), 'reserved', json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); return (int)$db->lastInsertId(); } catch (Throwable $e) { return 0; } } public static function settleReservation(int $reservationId, int $credits, string $provider, float $durationSeconds, array $metadata = []): void { if ($reservationId <= 0) { return; } try { $db = dbnmDb(); $db->prepare( "UPDATE user_tool_credit_reservations SET status = 'settled', settled_credits = ?, provider = ?, duration_seconds = ?, metadata_json = ?, settled_at = NOW() WHERE id = ? AND status = 'reserved'" )->execute([ max(0, $credits), substr($provider, 0, 40), round($durationSeconds, 2), json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $reservationId, ]); } catch (Throwable $e) { // Non-fatal audit path. } } public static function releaseReservation(int $reservationId, array $metadata = []): void { if ($reservationId <= 0) { return; } try { $db = dbnmDb(); $db->prepare( "UPDATE user_tool_credit_reservations SET status = 'released', metadata_json = ? WHERE id = ? AND status = 'reserved'" )->execute([ json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $reservationId, ]); } catch (Throwable $e) { // Non-fatal audit path. } } private static function recordLedger(int $userId, string $eventType, string $source, int $creditsDelta, array $metadata = []): void { try { $db = dbnmDb(); $db->prepare( 'INSERT INTO user_tool_credit_ledger (user_id, event_type, source, credits_delta, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, NOW())' )->execute([ $userId, substr($eventType, 0, 40), substr($source, 0, 100), $creditsDelta, json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); } catch (Throwable $e) { // Ledger is additive and should not block core credit behavior before migration is applied. } } }