import { db } from '../db/index.js'; import { bankMaximum } from './bank.js'; import { bankPageSize, type BankAccount, type BankOverview, type BankPage, type BankPending, type BankQuery, type BankTransfer } from '../../bank.js'; // A GUID owns one account even when telemetry has identities in both games. const identities = `WITH identities AS ( SELECT guid, name FROM ( SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS rank FROM leaderboard_players WHERE game IN ('t5', 't6') ) WHERE rank = 1 )`; const accounts = `${identities}, account_guids AS ( SELECT guid FROM bank_accounts UNION SELECT guid FROM bank_requests WHERE state = 'prepared' ), accounts AS ( SELECT g.guid, p.name, COALESCE(a.balance, 0) AS balance, COALESCE(a.balance, 0) - CASE WHEN r.operation = 'withdraw' THEN r.amount ELSE 0 END AS available, r.operation AS pendingOperation, r.amount AS pendingAmount, l.created_at AS lastActivity, l.amount AS lastAmount FROM account_guids g LEFT JOIN bank_accounts a ON a.guid = g.guid LEFT JOIN identities p ON p.guid = g.guid LEFT JOIN bank_requests r ON r.guid = g.guid AND r.state = 'prepared' LEFT JOIN bank_ledger l ON l.id = (SELECT id FROM bank_ledger WHERE guid = g.guid ORDER BY id DESC LIMIT 1) )`; // Canonical server names retain the game after match retention or server removal. const game = (alias: string) => `COALESCE(m.game, CASE WHEN substr(${alias}.server, 1, 3) = 't5_' THEN 't5' WHEN substr(${alias}.server, 1, 3) = 't6_' THEN 't6' END)`; const transfers = `${identities}, transfers AS ( SELECT l.id, l.guid, p.name, l.server, ${game('l')} AS game, l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, l.amount, l.balance, l.created_at AS createdAt FROM bank_ledger l LEFT JOIN identities p ON p.guid = l.guid LEFT JOIN leaderboard_matches m ON m.id = l.match_id )`; const pending = `${identities}, pending AS ( SELECT r.guid, p.name, r.request_id AS requestId, r.server, ${game('r')} AS game, r.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, r.operation, r.amount, r.created_at AS createdAt FROM bank_requests r LEFT JOIN identities p ON p.guid = r.guid LEFT JOIN leaderboard_matches m ON m.id = r.match_id WHERE r.state = 'prepared' )`; const emptyPage = (): BankPage => ({ entries: [], total: 0, offset: 0 }); export function bankOverview(query: BankQuery, now = new Date()): BankOverview { const handle = db(); return handle.transaction(() => { let maximum: number | null; try { maximum = bankMaximum(); } catch { // Invalid deposit configuration must not hide balances or recovery information. maximum = null; } const checkedAt = now.toISOString(); const summary = handle .prepare( `SELECT (SELECT COALESCE(SUM(balance), 0) FROM bank_accounts) AS totalBanked, (SELECT COUNT(*) FROM bank_accounts WHERE balance > 0) AS fundedAccounts, (SELECT COUNT(*) FROM bank_ledger WHERE created_at >= ? AND created_at <= ?) AS transfers24h, (SELECT COUNT(*) FROM bank_requests WHERE state = 'prepared') AS pending ` ) .get( new Date(now.getTime() - 86_400_000).toISOString(), checkedAt ) as BankOverview['summary']; const servers = ( handle .prepare( `SELECT server FROM bank_ledger UNION SELECT server FROM bank_requests WHERE state = 'prepared' ORDER BY server` ) .all() as { server: string }[] ).map((row) => row.server); function page( cte: string, table: string, where: string[], values: (string | number)[], order: string, requested: number ): BankPage { const scope = `FROM ${table}${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`; const { total } = handle .prepare(`${cte} SELECT COUNT(*) AS total ${scope}`) .get(...values) as { total: number }; const offset = Math.min( requested, Math.max(0, Math.floor((total - 1) / bankPageSize) * bankPageSize) ); const entries = handle .prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`) .all(...values, bankPageSize, offset) as T[]; return { entries, total, offset }; } const where: string[] = []; const values: (string | number)[] = []; if (query.q) { where.push( "(instr(lower(COALESCE(name, '')), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0)" ); values.push(query.q, query.q); } let accountPage = emptyPage(); let transactionPage = emptyPage(); let pendingPage = emptyPage(); if (query.view === 'accounts') { if (query.balance === 'funded') where.push('balance > 0'); if (query.balance === 'pending') where.push('pendingOperation IS NOT NULL'); if (query.balance === 'limit') { where.push('balance >= ?'); values.push(maximum ?? 1_000_001); } accountPage = page( accounts, 'accounts', where, values, query.sort === 'recent' ? 'lastActivity DESC, guid' : 'balance DESC, guid', query.offset ); } else { if (query.game) { where.push('game = ?'); values.push(query.game); } if (query.server) { where.push('server = ?'); values.push(query.server); } if (query.operation) { where.push( query.view === 'pending' ? 'operation = ?' : query.operation === 'deposit' ? 'amount > 0' : 'amount < 0' ); if (query.view === 'pending') values.push(query.operation); } if (query.from) { where.push('createdAt >= ?'); values.push(`${query.from}T00:00:00.000Z`); } if (query.to) { where.push('createdAt <= ?'); values.push(`${query.to}T23:59:59.999Z`); } if (query.view === 'transactions') transactionPage = page( transfers, 'transfers', where, values, 'createdAt DESC, id DESC', query.offset ); else pendingPage = page( pending, 'pending', where, values, 'createdAt, server, requestId', query.offset ); } let detail: BankOverview['detail'] = null; if (query.player) { let account = handle .prepare(`${accounts} SELECT * FROM accounts WHERE guid = ?`) .get(query.player) as BankAccount | undefined; if (!account) { const identity = handle .prepare(`${identities} SELECT * FROM identities WHERE guid = ?`) .get(query.player) as { guid: string; name: string } | undefined; if (identity) account = { ...identity, balance: 0, available: 0, pendingOperation: null, pendingAmount: null, lastActivity: null, lastAmount: null }; } if (account) detail = { account, pending: (handle .prepare(`${pending} SELECT * FROM pending WHERE guid = ?`) .get(query.player) as BankPending | undefined) ?? null, history: page( transfers, 'transfers', ['guid = ?'], [query.player], 'createdAt DESC, id DESC', query.historyOffset ) }; } return { query, maximum, checkedAt, summary, servers, accounts: accountPage, transactions: transactionPage, pending: pendingPage, detail }; })(); }