- 9 tasks: schema migration, capture data.balance, fetchVaultRecords column list, vaultLoop switch to MAX, backfill helper, wire backfill, buildTable column, index.php header, end-to-end smoke check - TDD: failing test in Task 2 before rewriting processLogEntries - Idempotent schema migration via PRAGMA table_info check - One-shot live-read backfill broadcasts latest balance to NULL rows
21 KiB
Vault Balance From Per-Entry API Value — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix the vault balance display so it shows the live vault total (per the Torn v2 API's data.balance field) instead of SUM(amount) of stored entries. The v2 API only returns the most recent ~100 entries per user, so summing deltas cannot yield the true current balance — only the per-entry running balance can.
Architecture: Add a running_balance INTEGER column to the vault table. Capture it from $entry['data']['balance'] in processLogEntries. Replace balance SQL from SUM(amount) to MAX(running_balance) per user. Add a one-shot live-read backfill that broadcasts the latest entry's data.balance to all NULL rows of a user. Add a "Balance After" column to the rendered table.
Tech Stack: PHP 8.1+, SQLite3 via PDO, cURL. No new dependencies.
Global Constraints
- PHP 8.1+ syntax only.
- Database schema gains one column (
running_balance INTEGER). Existing columns and their semantics are unchanged. INSERT … ON CONFLICT(id) DO NOTHINGis the only INSERT form used for log entries.- API authentication:
Authorization: ApiKey <key>header. - Pagination: follow
_metadata.links.nextURL until null. - Idempotency: every change must be safe to re-run (especially the schema migration and the backfill).
- Commits per task. Do not push.
- All existing helpers outside the rewritten ones stay byte-identical unless the task explicitly says otherwise.
Task 1: Schema — add running_balance column (idempotent on existing DBs)
Files:
- Modify:
functions.php(thegetDatabaseConnectionfunction only)
Interfaces:
-
Consumes: PDO from
PDO(DB_DSN, …). ReadsPRAGMA table_info(vault)to detect an existing column. -
Produces: a
vaulttable whose schema includesrunning_balance INTEGER. For existing DBs, the column is added viaALTER TABLE. -
Step 1: Update the CREATE TABLE statement
In functions.php, replace the CREATE TABLE IF NOT EXISTS vault (currently at lines 32-38) with the version that includes running_balance:
$createTableSQL = "CREATE TABLE IF NOT EXISTS vault (
id TEXT PRIMARY KEY,
user TEXT NOT NULL,
timestamp INTEGER NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
running_balance INTEGER
);";
- Step 2: Add the idempotent ALTER TABLE for existing DBs
Replace the if ($pdo === null) block's $pdo->exec($createTableSQL); call with the following code (keep the rest of the block intact):
$pdo->exec($createTableSQL);
// Add the running_balance column for databases created before the
// column was added. Idempotent: skip if it already exists.
$columns = $pdo->query("PRAGMA table_info(vault)")->fetchAll(PDO::FETCH_COLUMN, 1);
if (!in_array('running_balance', $columns, true)) {
$pdo->exec('ALTER TABLE vault ADD COLUMN running_balance INTEGER');
}
- Step 3: Verify the file parses
Run: php -l functions.php
Expected: No syntax errors detected in functions.php.
- Step 4: Verify the migration is idempotent on the live DB
Run once:
cd "C:\Users\ksolo\Projects\Games\Torn\Torn Vault Tracker"
php -r "
\$db = new PDO('sqlite:data/vault.db');
foreach(\$db->query('PRAGMA table_info(vault)') as \$c) { echo \$c['name'] . ' ' . \$c['type'] . PHP_EOL; }
"
Expected: running_balance INTEGER appears in the column list.
Run a second time (Page-load will do this; or re-run the script above twice in a row). Expected: no error (the ALTER TABLE fires only when the column is missing).
- Step 5: Commit
git add functions.php
git commit -m "Schema: add running_balance column to vault table"
Task 2: Rewrite fetchAndStoreLogPage and processLogEntries to capture data.balance (with failing test)
Files:
- Modify:
includes/utilities.php(fetchAndStoreLogPageandprocessLogEntries) - Modify:
tests/process_log_entries_test.php(extend to assertrunning_balanceround-trips)
Interfaces:
-
processLogEntries($logEntries, $user, $insertStmt): reads$entry['data']['balance'](int) and binds it as:running_balance. Ifdata.balanceis missing, binds NULL. -
fetchAndStoreLogPage($pdo, $user, $url): INSERT statement now includesrunning_balance. -
Step 1: Extend the failing test
In tests/process_log_entries_test.php, after the existing "row count after re-run" assertion, add these new assertions for the running_balance column. The fixture has 100 entries; the most recent entry's data.balance is 69235831 (newest, withdraw) and the oldest entry's data.balance is 509622821 (oldest, deposit) — both per the fixture verified during task planning.
Append after the existing "row count after re-run" block:
// round-trip the running_balance column from $entry['data']['balance']
$firstRunning = (int)$first['running_balance'];
$lastRunning = (int)$last['running_balance'];
assertSame(69235831, $firstRunning, 'first.running_balance');
assertSame(509622821, $lastRunning, 'last.running_balance');
- Step 2: Run the test and verify it fails
Run: php tests/process_log_entries_test.php
Expected: failure with first.running_balance: expected 69235831, got (likely 0 or NULL because the column doesn't get inserted yet).
- Step 3: Update
processLogEntries
In includes/utilities.php, replace the existing processLogEntries function (currently at lines 122-157) with:
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();
}
}
- Step 4: Update
fetchAndStoreLogPage's INSERT statement
In includes/utilities.php, replace the $insertStmt block inside fetchAndStoreLogPage (currently at lines 252-256) with:
$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'
);
- Step 5: Run the test and verify it passes
Run: php tests/process_log_entries_test.php
Expected: OK: processLogEntries v2 golden-file test passed (100 entries).
- Step 6: Verify the test file still parses
Run: php -l tests/process_log_entries_test.php
Expected: No syntax errors detected.
- Step 7: Commit
git add includes/utilities.php tests/process_log_entries_test.php
git commit -m "Capture running_balance from v2 API data.balance"
Task 3: Update fetchVaultRecords to include running_balance
Files:
- Modify:
includes/utilities.php(fetchVaultRecordsonly)
Interfaces:
-
Consumes: existing signature unchanged.
-
Produces: rows that include the
running_balancecolumn (existing callers that ignore unknown keys continue to work). -
Step 1: Replace
fetchVaultRecordswith explicit column list
In includes/utilities.php, replace the body of fetchVaultRecords (currently at lines 168-189) with:
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 [];
}
}
- Step 2: Verify the file parses
Run: php -l includes/utilities.php
Expected: No syntax errors detected.
- Step 3: Commit
git add includes/utilities.php
git commit -m "fetchVaultRecords: explicit column list including running_balance"
Task 4: Switch vaultLoop / generateBalance / getSpace to MAX(running_balance)
Files:
- Modify:
functions.php(the three functions)
Interfaces:
-
vaultLoop($name=null): returns the per-user vault balance (the most recent entry'srunning_balance) for the given user, or the sum of those across all users when$nameis null. Goes directly to PDO instead of fetching records. -
generateBalance($name=null): continues to wrapvaultLoop, returns the formatted value. -
getSpace($name=null): continues to computelimit - balanceagainst the same value. -
Step 1: Replace
vaultLoopwith a SQL-based implementation
In functions.php, replace vaultLoop (currently at lines 199-208) with:
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();
}
- Step 2: Verify the file parses
Run: php -l functions.php
Expected: No syntax errors detected.
- Step 3: Commit
git add functions.php
git commit -m "vaultLoop: use MAX(running_balance) per user instead of SUM(amount)"
Task 5: Add backfillRunningBalances helper
Files:
- Modify:
includes/utilities.php(append new function afterconsoleLog)
Interfaces:
-
backfillRunningBalances(PDO $pdo, string $user): void -
Counts rows where
running_balance IS NULLfor the user. If zero, returns. Otherwise hits the API withselections=log&log=5850,5851&limit=1&sort=DESCand uses the first entry'sdata.balanceto UPDATE all NULL rows for that user. -
Step 1: Append the new function
In includes/utilities.php, append this function at the end of the file (after consoleLog):
/**
* 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();
}
- Step 2: Verify the file parses
Run: php -l includes/utilities.php
Expected: No syntax errors detected.
- Step 3: Commit
git add includes/utilities.php
git commit -m "Add backfillRunningBalances helper for NULL running_balance rows"
Task 6: Wire backfillRunningBalances into the entry points
Files:
- Modify:
functions.php(backfillUserLogsandsyncUserLogs)
Interfaces:
-
backfillUserLogs($user): after the pagination loop, callbackfillRunningBalances($pdo, $user). -
syncUserLogs($user): after the pagination loop, callbackfillRunningBalances($pdo, $user). -
Step 1: Update
backfillUserLogs
In functions.php, replace the body of backfillUserLogs (currently at lines 66-74) with:
function backfillUserLogs($user) {
$url = 'https://api.torn.com/v2/user?selections=log&log=5850,5851';
$pdo = getDatabaseConnection();
do {
$next = fetchAndStoreLogPage($pdo, $user, $url);
$url = $next;
} while ($next !== null);
backfillRunningBalances($pdo, $user);
}
- Step 2: Update
syncUserLogs
In functions.php, replace the body of syncUserLogs (currently at lines 87-106) with:
function syncUserLogs($user) {
if (dbNew()) {
backfillUserLogs($user);
return;
}
$pdo = getDatabaseConnection();
$stmt = $pdo->prepare('SELECT MAX(timestamp) AS max_ts FROM vault WHERE user = :user');
$stmt->bindValue(':user', $user);
$stmt->execute();
$lastTs = (int)$stmt->fetch(PDO::FETCH_ASSOC)['max_ts'];
$url = "https://api.torn.com/v2/user?selections=log&log=5850,5851&from=" . ($lastTs + 1);
do {
$next = fetchAndStoreLogPage($pdo, $user, $url);
$url = $next;
} while ($next !== null);
backfillRunningBalances($pdo, $user);
}
- Step 3: Verify the file parses
Run: php -l functions.php
Expected: No syntax errors detected.
- Step 4: Commit
git add functions.php
git commit -m "Invoke backfillRunningBalances after sync/backfill"
Task 7: Add "Balance After" column to buildTable
Files:
- Modify:
functions.php(buildTableonly)
Interfaces:
-
buildTable(): emits a 5th<td>withnumber_format($entry['running_balance'], 0)per row. Therunning_balancefield may be NULL on freshly-backfilled rows; render as—(em-dash) so the table doesn't show "0" by default. -
Step 1: Update
buildTable
In functions.php, replace the buildTable function (currently at lines 155-181) with:
function buildTable () {
$records = fetchVaultRecords();
foreach ($records as $entry) {
$user = $entry['user'];
$timestamp = $entry['timestamp'];
$description = $entry['description'];
$amount = $entry['amount'];
$sign = getSign($amount);
if ($description == 'Vault withdraw') {
$class = 'debit';
$amount = substr($amount, 1); // Remove the negative sign
} else {
$class = 'credit';
}
$amount = number_format($amount, 0); // Format the amount as an integer
$runningBalance = isset($entry['running_balance']) && $entry['running_balance'] !== null
? '$' . number_format((int)$entry['running_balance'], 0)
: '—';
echo '<tr class="'.$class.'">';
echo '<td>'.$user.'</td>';
echo '<td>'.date("F j, Y / H:i", $timestamp).'</td>';
echo '<td>'.$description.'</td>';
echo '<td>'.$sign.$amount.'</td>';
echo '<td>'.$runningBalance.'</td>';
echo '</tr>';
}
}
- Step 2: Verify the file parses
Run: php -l functions.php
Expected: No syntax errors detected.
- Step 3: Commit
git add functions.php
git commit -m "buildTable: add Balance After column showing per-entry running_balance"
Task 8: Add the column header in index.php
Files:
- Modify:
index.php(one<th>element)
Interfaces:
-
Just the HTML table header.
-
Step 1: Add the
<th>Balance After</th>element
In index.php, after the existing <th><h4>Amount</h4></th> (currently at line 69), add:
<th><h4>Balance After</h4></th>
The result should read:
<th><h4>User</h4></th>
<th><h4>Date / Time (TCT)</h4></th>
<th><h4>Operation</h4></th>
<th><h4>Amount</h4></th>
<th><h4>Balance After</h4></th>
- Step 2: Verify the file parses
Run: php -l index.php
Expected: No syntax errors detected.
- Step 3: Commit
git add index.php
git commit -m "index.php: add Balance After column header"
Task 9: End-to-end smoke check
Files: No code changes - just verification.
- Step 1: Run the test suite
Run: php tests/process_log_entries_test.php
Expected: OK: processLogEntries v2 golden-file test passed (100 entries).
- Step 2: Verify the schema migration survived on the live DB
Run:
cd "C:\Users\ksolo\Projects\Games\Torn\Torn Vault Tracker"
php -r "
\$db = new PDO('sqlite:data/vault.db');
foreach(\$db->query('PRAGMA table_info(vault)') as \$c) { echo \$c['name'] . ' ' . \$c['type'] . PHP_EOL; }
echo 'Total rows: ' . \$db->query('SELECT COUNT(*) FROM vault')->fetchColumn() . PHP_EOL;
echo 'Rows with running_balance after backfill: ' . \$db->query('SELECT COUNT(*) FROM vault WHERE running_balance IS NOT NULL')->fetchColumn() . PHP_EOL;
echo 'Rows with NULL running_balance: ' . \$db->query('SELECT COUNT(*) FROM vault WHERE running_balance IS NULL')->fetchColumn() . PHP_EOL;
"
Expected: column list shows running_balance INTEGER. After Task 6's backfill has run on the live DB (manually once via the test instance), the NULL count should be 0.
- Step 3: Probe the test instance
Open the test instance in the browser (or curl http://127.0.0.1:8001/). The right-side "Balances" panel should show a positive total ($615,423,760 = $69M Zarathos + $546M Symos), not a negative sum. The transactions table should show a 5th "Balance After" column with $X values.
- Step 4: Verify no v1 references remain
Run:
cd "C:\Users\ksolo\Projects\Games\Torn\Torn Vault Tracker"
grep -n "firstRun\|?key=" functions.php index.php includes/utilities.php includes/exceptions.php
Expected: no matches.
- Step 5: Commit if any incidental fixes were needed
If Step 3 or 4 produced any required fixes, commit them. Otherwise, no commit.
Self-Review Notes
Spec coverage:
| Requirement | Task |
|---|---|
Add running_balance column to vault table |
Task 1 |
| Idempotent schema migration for existing DBs | Task 1 (PRAGMA check + ALTER TABLE) |
Capture data.balance from v2 API in processLogEntries |
Task 2 |
Update INSERT statement in fetchAndStoreLogPage |
Task 2 |
Update fetchVaultRecords to include new column |
Task 3 |
Replace SUM(amount) with MAX(running_balance) per user |
Task 4 |
| Backfill NULL rows from latest API balance | Task 5 |
| Wire backfill into entry points | Task 6 |
Add "Balance After" column to buildTable |
Task 7 |
Add header <th> in index.php |
Task 8 |
Test asserts running_balance round-trips |
Task 2 |
| End-to-end smoke check | Task 9 |
Placeholder scan: No "TODO", "TBD", "implement later", or vague instructions.
Type consistency: The backfillRunningBalances(PDO $pdo, string $user): void signature in Task 5 matches the call sites in Task 6 (backfillRunningBalances($pdo, $user) in both backfillUserLogs and syncUserLogs). The vaultLoop SQL in Task 4 uses MAX(running_balance) for the per-user case and SUM(max_balance) over a per-user MAX subquery for the all-users case — both return int, matching the existing (int) cast in generateBalance/getSpace. The fetchAndStoreLogPage INSERT in Task 2 includes :running_balance matching the new bind in processLogEntries.
Risks addressed:
- The schema migration is idempotent (PRAGMA check) and safe for existing DBs.
- The backfill is a no-op when no NULL rows exist for a user.
- The
running_balancecolumn is nullable, so entries missingdata.balancefrom the API don't break the insert. - All existing SQL (purported to depend on the table schema) keeps working because the new column is
INTEGERwith null default.