Add Bank accounts, transactions, and pending transfer views
CI / checks (push) Successful in 7m50s

This commit is contained in:
2026-10-13 11:50:26 -04:00
parent d6147077d7
commit e85dc58130
19 changed files with 1888 additions and 7 deletions
+116
View File
@@ -0,0 +1,116 @@
export const bankPageSize = 25;
export const bankAttentionAgeMs = 5 * 60_000;
export interface BankQuery {
view: 'accounts' | 'transactions' | 'pending';
q: string;
balance: '' | 'funded' | 'limit' | 'pending';
sort: 'balance' | 'recent';
game: '' | 't5' | 't6';
server: string;
operation: '' | 'deposit' | 'withdraw';
from: string;
to: string;
offset: number;
player: string;
historyOffset: number;
}
export function readBankQuery(params: URLSearchParams): BankQuery {
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
const value = params.get(key) || fallback;
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
return value as T;
}
function text(key: string, max: number) {
const value = (params.get(key) ?? '').trim();
if (value.length > max) throw new Error(`${key} is too long.`);
return value;
}
function offset(key: string) {
const value = Number(params.get(key) || 0);
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid page offset.');
return Math.floor(value / bankPageSize) * bankPageSize;
}
function date(key: string) {
const value = text(key, 10);
if (
value &&
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString().slice(0, 10) !== value)
)
throw new Error('Use a valid date in YYYY-MM-DD format.');
return value;
}
const from = date('from');
const to = date('to');
if (from && to && from > to) throw new Error('The end date must follow the start date.');
return {
view: choice('view', ['accounts', 'transactions', 'pending'], 'accounts'),
q: text('q', 100),
balance: choice('balance', ['', 'funded', 'limit', 'pending'], ''),
sort: choice('sort', ['balance', 'recent'], 'balance'),
game: choice('game', ['', 't5', 't6'], ''),
server: text('server', 256),
operation: choice('operation', ['', 'deposit', 'withdraw'], ''),
from,
to,
offset: offset('offset'),
player: text('player', 128),
historyOffset: offset('historyOffset')
};
}
export interface BankIdentity {
guid: string;
name: string | null;
}
export interface BankAccount extends BankIdentity {
balance: number;
available: number;
pendingOperation: 'deposit' | 'withdraw' | null;
pendingAmount: number | null;
lastActivity: string | null;
lastAmount: number | null;
}
export interface BankTransfer extends BankIdentity {
id: number;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
amount: number;
balance: number;
createdAt: string;
}
export interface BankPending extends BankIdentity {
requestId: string;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
operation: 'deposit' | 'withdraw';
amount: number;
createdAt: string;
}
export interface BankPage<T> {
entries: T[];
total: number;
offset: number;
}
export interface BankOverview {
query: BankQuery;
maximum: number | null;
checkedAt: string;
summary: { totalBanked: number; fundedAccounts: number; transfers24h: number; pending: number };
servers: string[];
accounts: BankPage<BankAccount>;
transactions: BankPage<BankTransfer>;
pending: BankPage<BankPending>;
detail: {
account: BankAccount;
pending: BankPending | null;
history: BankPage<BankTransfer>;
} | null;
}
+2 -1
View File
@@ -1,6 +1,6 @@
const origin = 'https://electric-cherry.invalid';
const pagePath =
/^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
@@ -60,6 +60,7 @@ export function returnLabel(href: string): string {
maintenance: 'Maintenance',
provisioning: 'Server queues',
leaderboard: 'Leaderboard',
bank: 'Bank',
matches: 'Match History',
metrics: 'Metrics'
};
+1
View File
@@ -78,6 +78,7 @@ export const navigationGroups: NavigationGroup[] = [
items: [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Matches', href: '/matches' },
{ label: 'Bank', href: '/bank' },
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
]
},
+228
View File
@@ -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
};
})();
}
@@ -0,0 +1,55 @@
<script lang="ts">
import { bankPageSize } from '$lib/bank.js';
import Button from '../primitives/Button.svelte';
let {
total,
offset,
disabled = false,
onchange,
label = 'results'
}: {
total: number;
offset: number;
disabled?: boolean;
onchange: (offset: number) => void;
label?: string;
} = $props();
</script>
<nav class="pagination" aria-label={`${label} pagination`}>
<span role="status"
>{total ? offset + 1 : 0}–{Math.min(offset + bankPageSize, total)} of {total.toLocaleString()}
{label}</span
>
{#if total > bankPageSize}
<div>
<Button
size="sm"
disabled={disabled || offset === 0}
onclick={() => onchange(offset - bankPageSize)}>Previous</Button
>
<Button
size="sm"
disabled={disabled || offset + bankPageSize >= total}
onclick={() => onchange(offset + bankPageSize)}>Next</Button
>
</div>
{/if}
</nav>
<style>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
padding-block: var(--space-3);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pagination div {
display: flex;
gap: var(--space-2);
}
</style>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { BankPending } from '$lib/bank.js';
import { contextHref } from '$lib/navigation-context.js';
import CopyValue from '../CopyValue.svelte';
import RelativeTime from '../RelativeTime.svelte';
import ServerIdentity from '../ServerIdentity.svelte';
let { transfer, source }: { transfer: BankPending; source: URL } = $props();
</script>
<div class="pending-details">
<p>
Waiting for the game server to confirm or cancel this {transfer.operation === 'deposit'
? 'deposit'
: 'withdrawal'}.
</p>
<p>
{transfer.operation === 'deposit'
? 'These points have not been added to the bank balance. Deposit capacity is reserved until the transfer settles.'
: 'These points are reserved and cannot be withdrawn again until the transfer settles.'}
</p>
<p>
Restore the server’s connection and allow it to retry. Pending transfers do not expire
automatically.
</p>
<div>
<ServerIdentity name={transfer.server} /> · <RelativeTime value={transfer.createdAt} />
</div>
{#if transfer.matchAvailable}<a href={contextHref(`/matches/${transfer.matchId}`, source)}
>View match #{transfer.matchId}</a
>{:else if transfer.matchId}<span>Match #{transfer.matchId} · History expired</span>{/if}
<details>
<summary>Transfer details</summary><CopyValue
value={transfer.requestId}
label="Request ID"
/><CopyValue value={transfer.server} label="Server ID" />
</details>
</div>
<style>
.pending-details {
display: grid;
gap: var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
}
p {
margin: 0;
}
summary {
padding-block: var(--space-2);
color: var(--text-primary);
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
import type { BankTransfer } from '$lib/bank.js';
import { contextHref } from '$lib/navigation-context.js';
import RelativeTime from '../RelativeTime.svelte';
import ServerIdentity from '../ServerIdentity.svelte';
import EmptyState from '../primitives/EmptyState.svelte';
let {
entries,
source,
onPlayer,
compact = false
}: {
entries: BankTransfer[];
source: URL;
onPlayer?: (guid: string) => void;
compact?: boolean;
} = $props();
const number = new Intl.NumberFormat('en-US');
</script>
{#if entries.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="bank-table-scroll" role="region" aria-label="Bank transactions" tabindex="0">
<table class="bank-table" class:compact>
<caption class="sr-only">Completed bank transfers</caption>
<thead
><tr
><th scope="col">Time</th>{#if onPlayer}<th scope="col">Player</th>{/if}<th
scope="col">Transfer</th
><th scope="col">Balance after</th><th scope="col">Server / match</th></tr
></thead
>
<tbody>
{#each entries as entry (entry.id)}
<tr>
<td><RelativeTime value={entry.createdAt} /></td>
{#if onPlayer}<td
><button class="bank-player" onclick={() => onPlayer?.(entry.guid)}
>{entry.name ?? entry.guid}</button
><small class="bank-id">{entry.guid}</small></td
>{/if}
<td class="bank-number"
><strong
>{entry.amount > 0 ? '+' : '−'}{number.format(
Math.abs(entry.amount)
)}</strong
><small>{entry.amount > 0 ? 'Deposit' : 'Withdrawal'}</small></td
>
<td class="bank-number">{number.format(entry.balance)}</td>
<td
><ServerIdentity name={entry.server} /><small
>{#if entry.matchAvailable}<a
href={contextHref(`/matches/${entry.matchId}`, source)}
>Match #{entry.matchId}</a
>{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}Match
unavailable{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<EmptyState
title="No transactions found"
description="Completed deposits and withdrawals appear here. Try changing your filters if you expected to see more."
/>
{/if}
+68
View File
@@ -0,0 +1,68 @@
.bank-table-scroll {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.bank-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
.bank-table th {
color: var(--text-tertiary);
background: var(--surface-sunken);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.bank-table th,
.bank-table td {
padding: var(--space-4);
vertical-align: top;
}
.bank-table tbody tr + tr {
border-top: 1px solid var(--border-subtle);
}
.bank-table td {
min-width: 130px;
}
.bank-table small {
display: block;
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.bank-table .bank-id {
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.bank-number {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bank-player {
padding: 0;
background: none;
border: 0;
color: var(--accent-primary-hover);
text-align: left;
font: inherit;
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
cursor: pointer;
}
.bank-player:hover {
text-decoration: underline;
}
.bank-table.compact th,
.bank-table.compact td {
padding: var(--space-3);
}
.bank-table.compact td {
min-width: 100px;
}
.bank-table .bank-warning {
color: var(--state-warning);
}