This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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
|
||||
};
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user