Files
electric-cherry/src/lib/server/fleet/bank-overview.ts
T
2026-10-13 11:50:26 -04:00

229 lines
9.0 KiB
TypeScript

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 = <T>(): BankPage<T> => ({ 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<T>(
cte: string,
table: string,
where: string[],
values: (string | number)[],
order: string,
requested: number
): BankPage<T> {
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<BankAccount>();
let transactionPage = emptyPage<BankTransfer>();
let pendingPage = emptyPage<BankPending>();
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
};
})();
}