* @license Unlicense https://unlicense.org/ * @link https://github.com/ksolomon/Torn-Vault-Tracker */ // Test hook for fetchLiveVaultBalance(). When set, this closure is called // instead of executeApiCall. Set $GLOBALS['liveBalanceTestHook'] to a // closure($url, $apiKey): array in test code. Production code leaves it null. $GLOBALS['liveBalanceTestHook'] = $GLOBALS['liveBalanceTestHook'] ?? null; /** * Checks if the database is new and empty. * * This function checks if the 'vault' table in the database has any entries. * If it does not, it returns true, indicating that the database is new and * empty. Otherwise, it returns false. * * @return boolean True if the database is new and empty, false otherwise. */ function dbNew() { $pdo = getDatabaseConnection(); $stmt = $pdo->query("SELECT COUNT(*) FROM vault"); return $stmt->fetchColumn() == 0; } /** * Ensures that a user has an API key configured. * * @param string $user The user to check the API key for * * @return void * * @throws Exception If the user does not have an API key configured */ function ensureUserHasApiKey($user) { if (!array_key_exists($user, USER_KEYS)) { throw new ApiKeyMissingException("User does not have an API key configured."); } } /** * Executes a GET request to the given API endpoint and returns the JSON response as an associative array. * * Uses the v2 Torn API authentication scheme by sending the API key in an * `Authorization: ApiKey ` header. The key is never transmitted as a * query parameter. * * @param string $apiEndpoint The URL of the API endpoint to call * @param string $apiKey The v2 API key to authenticate with * * @throws ApiKeyMissingException If the API key is empty * @throws CurlErrorException If the cURL call fails * @throws JsonDataException If the response body cannot be decoded as JSON * * @return array The JSON response from the API */ function executeApiCall($apiEndpoint, $apiKey) { if (empty($apiKey)) { throw new ApiKeyMissingException('API key is required for executeApiCall.'); } $headers = ["Authorization: ApiKey $apiKey"]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $apiEndpoint); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $response = curl_exec($ch); if (curl_errno($ch)) { throw new CurlErrorException("cURL error: " . curl_error($ch)); } curl_close($ch); $responseData = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new JsonDataException("Failed to decode JSON response: " . json_last_error_msg()); } return $responseData; } /** * Validates the API response data for the presence of a log array. * * @param array $responseData The API response data to validate. * * @return void * * @throws Exception If the log data is missing or not an array. */ function validateApiResponse($responseData) { if (!isset($responseData['log']) || !is_array($responseData['log'])) { throw new ApiValidationException('Invalid log data received from the API.'); } } /** * Process an array of log entries retrieved from the Torn v2 API. * * For each entry, extracts the v2 shape (`id`, `timestamp`, * `details.title`, `data.deposited` | `data.withdrawn`) and inserts it * via the prepared statement, which uses `ON CONFLICT(id) DO NOTHING` * for idempotency. * * @param array $logEntries The array of v2 log entries to process * @param string $user The user whose log entries are being processed * @param PDOStatement $insertStmt A prepared statement for the idempotent * insert (`INSERT … ON CONFLICT(id) DO NOTHING`) * * @return void */ function processLogEntries($logEntries, $user, $insertStmt) { foreach ($logEntries as $entry) { $id = $entry['id'] ?? null; if (!$id) { consoleLog('Skipping entry with no id: ' . print_r($entry, true)); continue; } $timestamp = $entry['timestamp'] ?? null; $description = $entry['details']['title'] ?? null; $hasDeposit = isset($entry['data']['deposited']); $hasWithdraw = isset($entry['data']['withdrawn']); if ($hasDeposit) { $amount = (int)$entry['data']['deposited']; } elseif ($hasWithdraw) { $amount = -((int)$entry['data']['withdrawn']); } else { consoleLog('Skipping entry ' . $id . ' with no deposited/withdrawn: ' . print_r($entry, true)); continue; } if ($timestamp === null || $description === null) { consoleLog('Skipping entry ' . $id . ' missing timestamp or details.title: ' . print_r($entry, true)); continue; } $runningBalance = isset($entry['data']['balance']) ? (int)$entry['data']['balance'] : null; $insertStmt->bindValue(':id', $id); $insertStmt->bindValue(':user', $user); $insertStmt->bindValue(':timestamp', $timestamp); $insertStmt->bindValue(':description', $description); $insertStmt->bindValue(':amount', $amount); $insertStmt->bindValue(':running_balance', $runningBalance); $insertStmt->execute(); } } /** * Fetch vault records from the database * If a user is provided, only that user's records * are returned. Otherwise, all records are returned. * * @param string $user (optional) The user to filter by * * @return array An array of records, or an empty array on error */ function fetchVaultRecords($user = null) { $pdo = getDatabaseConnection(); if ($user) { $query = "SELECT id, user, timestamp, description, amount, running_balance FROM vault WHERE user = :user ORDER BY timestamp DESC"; $params = [':user' => $user]; } else { $query = "SELECT id, user, timestamp, description, amount, running_balance FROM vault ORDER BY timestamp DESC"; $params = []; } try { $stmt = $pdo->prepare($query); $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { echo "Error fetching records: " . $e->getMessage(); return []; } } /** * Iterate over all vault records for the given user (or all users if no user is given), * * @param string|null $name (optional) The user to loop over records for. * If not provided, all users' records will be looped over. * * @return int The total balance of the given user (or all users if no user is given). */ function vaultLoop ($name=null) { $pdo = getDatabaseConnection(); if ($name === null) { $stmt = $pdo->query( 'SELECT COALESCE(SUM(max_balance), 0) FROM ' . '(SELECT MAX(running_balance) AS max_balance FROM vault GROUP BY user)' ); return (int)$stmt->fetchColumn(); } $stmt = $pdo->prepare('SELECT MAX(running_balance) FROM vault WHERE user = :user'); $stmt->bindValue(':user', $name); $stmt->execute(); return (int)$stmt->fetchColumn(); } /** * Returns a string indicating the sign of a number, with a dollar symbol. * * @param int|float $number The number to check. * * @return string A string containing a dollar sign and a negative sign if the * number is negative. */ function getSign($number) { if (substr($number, 0, 1) == '-') { return '-$'; } return '$'; } /** * Fetch a single page of log entries from the Torn v2 API and store them. * * Performs one HTTP request, validates the response, inserts each vault * log entry (idempotently via INSERT ... ON CONFLICT), and returns the * pagination cursor (`_metadata.links.next`) if more pages remain. * * @param PDO $pdo Database connection used to insert vault entries. * @param string $user The user whose log entries are being fetched. * @param string $url Full URL for the v2 API request. * * @throws ApiKeyMissingException If no API key is configured for the user. * @throws CurlErrorException If the HTTP request fails. * @throws JsonDataException If the response body is not valid JSON. * @throws ApiValidationException If the response is missing the `log` array. * * @return string|null The `_metadata.links.next` URL, or null when there are no more pages. */ function fetchAndStoreLogPage($pdo, $user, $url) { if (!array_key_exists($user, USER_KEYS)) { throw new ApiKeyMissingException("User does not have an API key configured."); } $responseData = executeApiCall($url, USER_KEYS[$user]); validateApiResponse($responseData); $insertStmt = $pdo->prepare( 'INSERT INTO vault (id, user, timestamp, description, amount, running_balance) ' . 'VALUES (:id, :user, :timestamp, :description, :amount, :running_balance) ' . 'ON CONFLICT(id) DO NOTHING' ); processLogEntries($responseData['log'], $user, $insertStmt); return $responseData['_metadata']['links']['next'] ?? null; } /** * Print a variable to the console for debugging purposes. * * @param mixed $data The data to print to the console. * * @return void */ function consoleLog( $data ) { echo ''; } /** * Backfill the running_balance column for any user rows that have NULL. * * The v2 API's `data.balance` field is the vault balance after each entry. * If the column is missing for a row, treating it as NULL means * MAX(running_balance) ignores it. This routine fetches the latest entry's * balance from the API and broadcasts it to all NULL rows of the user, * so the displayed balance becomes accurate after the first sync following * the schema upgrade. * * @param PDO $pdo Database connection. * @param string $user The user whose NULL rows should be backfilled. * * @return void */ function backfillRunningBalances($pdo, $user) { if (!array_key_exists($user, USER_KEYS)) { throw new ApiKeyMissingException("User does not have an API key configured."); } $countStmt = $pdo->prepare('SELECT COUNT(*) FROM vault WHERE user = :user AND running_balance IS NULL'); $countStmt->bindValue(':user', $user); $countStmt->execute(); if ((int)$countStmt->fetchColumn() === 0) { return; } $url = 'https://api.torn.com/v2/user?selections=log&log=5850,5851&limit=1&sort=DESC'; $responseData = executeApiCall($url, USER_KEYS[$user]); validateApiResponse($responseData); if (empty($responseData['log'])) { return; } $latest = $responseData['log'][0]; if (!isset($latest['data']['balance'])) { return; } $balance = (int)$latest['data']['balance']; $updateStmt = $pdo->prepare('UPDATE vault SET running_balance = :balance WHERE user = :user AND running_balance IS NULL'); $updateStmt->bindValue(':balance', $balance); $updateStmt->bindValue(':user', $user); $updateStmt->execute(); } /** * Fetch the live vault balance for a user from the v2 /money endpoint. * * Returns the `money.vault` value as an int, or null if the API call * fails, returns invalid JSON, or doesn't include `money.vault`. The * result is cached for the duration of the PHP request so multiple * callers (e.g., `vaultLoop` for one user, then the all-users sum) * don't re-fetch. * * @param string $user The user whose vault balance to fetch. * * @return int|null The vault amount in pennies, or null on failure. */ function fetchLiveVaultBalance($user) { static $prodCache = []; // production: hook is null, plain per-request array static $testCache = null; // test: keyed by hook closure (weakly held) if (!array_key_exists($user, USER_KEYS)) { return null; } $hook = $GLOBALS['liveBalanceTestHook'] ?? null; if ($hook === null) { // Production path: plain array cache. if (array_key_exists($user, $prodCache)) { return $prodCache[$user]; } try { $responseData = executeApiCall('https://api.torn.com/v2/user?selections=money', USER_KEYS[$user]); if (!isset($responseData['money']['vault'])) { $prodCache[$user] = null; return null; } $prodCache[$user] = (int)$responseData['money']['vault']; return $prodCache[$user]; } catch (Exception $e) { $prodCache[$user] = null; return null; } } // Test path: key by hook identity so test scenarios get fresh fetches. if ($testCache === null) { $testCache = new \WeakMap(); } if (isset($testCache[$hook]) && array_key_exists($user, $testCache[$hook])) { return $testCache[$hook][$user]; } try { $responseData = $hook('https://api.torn.com/v2/user?selections=money', USER_KEYS[$user]); if (!isset($responseData['money']['vault'])) { $testCache[$hook] = [$user => null]; return null; } $testCache[$hook] = [$user => (int)$responseData['money']['vault']]; return $testCache[$hook][$user]; } catch (Exception $e) { $testCache[$hook] = [$user => null]; return null; } }