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
+22 -3
View File
@@ -195,14 +195,33 @@ in the actual Windows/Plutonium deployment before rollout.
## History and validation ## History and validation
Player profiles show the shared balance, pending status and 20 newest ledger The **Players → Bank** page shows fleet-wide balances, funded accounts, completed
entries, including signed amounts, resulting balances, server, time and match. transfers in the last 24 hours and pending transfers. Accounts can be searched by
player name or GUID, sorted by balance or recent completed activity, and filtered
to funded accounts, accounts at or above the configured limit, or pending transfers.
Balances always remain shared across both games.
Transactions can be filtered by player, game, server, direction and inclusive UTC
dates. Pending transfers show oldest first, with an attention indicator after five
minutes and details explaining the reservation. This indicator does not expire or
cancel a transfer. Both views retain historical server identities and match IDs
after retention; unavailable matches have no live link.
Selecting a player opens a drawer with their balance, available points, pending
transfer and paginated history. Filters and pagination are retained in the URL,
including when following a match or player-profile link. The page refreshes every
30 seconds while visible. Summary counts always cover the whole fleet. Bank is
read-only for all browser roles; transfers continue to originate in game.
Player profiles show the shared balance, pending status and three newest ledger
entries, with a **View bank history** link to the drawer. Entries include signed
amounts, resulting balances, server, time and match.
Accounts, request IDs and ledger entries have no cascading match/server foreign Accounts, request IDs and ledger entries have no cascading match/server foreign
keys and are not deleted by match retention or server deletion. Retained IDs keys and are not deleted by match retention or server deletion. Retained IDs
also keep old retries idempotent. Expired match links become plain historical also keep old retries idempotent. Expired match links become plain historical
IDs. Include these tables in normal database backups. IDs. Include these tables in normal database backups.
Run `npx vitest run tests/unit/bank.test.ts`, then the usual lint, type checks, Run `npx vitest run tests/unit/bank.test.ts tests/unit/bank-overview.test.ts tests/client/bank-page.test.ts`, then the usual lint, type checks,
tests and build. TWS supplies `node tools/check_bank.mjs` and tests and build. TWS supplies `node tools/check_bank.mjs` and
`python3 tools/test_plugin_bank.py`. Live validation must cover both games: `python3 tools/test_plugin_bank.py`. Live validation must cover both games:
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
+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 origin = 'https://electric-cherry.invalid';
const pagePath = 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. */ /** Only return to application pages, never an external URL or a mutation/API endpoint. */
export function safeReturnHref(value: string | null | undefined, fallback: string): string { export function safeReturnHref(value: string | null | undefined, fallback: string): string {
@@ -60,6 +60,7 @@ export function returnLabel(href: string): string {
maintenance: 'Maintenance', maintenance: 'Maintenance',
provisioning: 'Server queues', provisioning: 'Server queues',
leaderboard: 'Leaderboard', leaderboard: 'Leaderboard',
bank: 'Bank',
matches: 'Match History', matches: 'Match History',
metrics: 'Metrics' metrics: 'Metrics'
}; };
+1
View File
@@ -78,6 +78,7 @@ export const navigationGroups: NavigationGroup[] = [
items: [ items: [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' }, { label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Matches', href: '/matches' }, { label: 'Matches', href: '/matches' },
{ label: 'Bank', href: '/bank' },
{ label: 'Usage', href: '/metrics', shortcut: 'm' } { 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);
}
+13
View File
@@ -0,0 +1,13 @@
import { error, json } from '@sveltejs/kit';
import { readBankQuery } from '$lib/bank.js';
import { bankOverview } from '$lib/server/fleet/bank-overview.js';
export function GET({ url }: { url: URL }) {
let query;
try {
query = readBankQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.');
}
return json(bankOverview(query), { headers: { 'Cache-Control': 'no-store' } });
}
+13
View File
@@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { readBankQuery } from '$lib/bank.js';
import { bankOverview } from '$lib/server/fleet/bank-overview.js';
export function load({ url }: { url: URL }) {
let query;
try {
query = readBankQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.');
}
return { report: bankOverview(query) };
}
+693
View File
@@ -0,0 +1,693 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import { bankAttentionAgeMs, type BankOverview, type BankQuery } from '$lib/bank.js';
import { contextHref } from '$lib/navigation-context.js';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import BankPagination from '$ui/components/bank/BankPagination.svelte';
import BankTransfers from '$ui/components/bank/BankTransfers.svelte';
import BankPendingDetails from '$ui/components/bank/BankPendingDetails.svelte';
import '$ui/styles/bank.css';
let { data }: { data: import('./$types').PageData } = $props();
const number = new Intl.NumberFormat('en-US');
let snapshot = $state<BankOverview | null>(null);
const report = $derived(snapshot ?? data.report);
let filters = $state<BankQuery>(untrack(() => ({ ...data.report.query })));
let refreshError = $state('');
let navigationError = $state('');
let updatedAt = $state<number | null>(null);
let refreshing = $state(false);
let controller: AbortController | undefined;
let generation = 0;
const busy = $derived(!!$navigating);
const results = $derived(report[report.query.view]);
const tabs = $derived([
{ id: 'accounts', label: 'Accounts' },
{ id: 'transactions', label: 'Transactions' },
{ id: 'pending', label: 'Pending', badge: report.summary.pending || null }
]);
const serverOptions = $derived([
{ value: '', label: 'All servers' },
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
(server) => ({ value: server, label: serverTitle(server) })
)
]);
$effect(() => {
const initial = data.report;
untrack(() => {
snapshot = null;
filters = { ...initial.query };
refreshError = '';
});
});
function cancelRefresh() {
generation++;
controller?.abort();
refreshing = false;
}
beforeNavigate(cancelRefresh);
afterNavigate(() => {
updatedAt = Date.now();
navigationError = '';
});
async function refresh(signal?: AbortSignal) {
if (busy || refreshing) return;
const current = ++generation;
const request = new AbortController();
controller = request;
const abort = () => request.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) request.abort();
const timeout = setTimeout(abort, 30_000);
refreshing = true;
try {
const response = await fetch(`/api/bank${$page.url.search}`, {
signal: request.signal
});
if (!response.ok) throw new Error('Bank refresh failed.');
const next: BankOverview = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation) refreshError = 'Bank updates paused.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 30_000, false);
return () => {
stop();
cancelRefresh();
};
});
async function navigate(changes: Record<string, string | number | null>) {
const params = new URLSearchParams($page.url.searchParams);
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, String(value));
}
navigationError = '';
try {
await goto(`/bank?${params}`, { keepFocus: true, noScroll: true });
} catch {
navigationError = 'Could not load this bank view. Please try again.';
}
}
function changeView(view: string) {
void navigate({
view,
offset: null,
balance: null,
sort: null,
game: null,
server: null,
operation: null,
from: null,
to: null
});
}
function applyFilters(event: SubmitEvent) {
event.preventDefault();
void navigate({
q: filters.q,
balance: filters.balance,
sort: filters.sort,
game: filters.game,
server: filters.server,
operation: filters.operation,
from: filters.from,
to: filters.to,
offset: null
});
}
const openPlayer = (guid: string) => {
void navigate({ player: guid, historyOffset: null });
};
function setDrawerOpen(open: boolean) {
if (!open && report.query.player && !busy)
void navigate({ player: null, historyOffset: null });
}
const isOlder = (createdAt: string) =>
Date.parse(report.checkedAt) - Date.parse(createdAt) >= bankAttentionAgeMs;
const capacity = (balance: number) =>
report.maximum ? Math.round((balance / report.maximum) * 100) : 0;
</script>
<svelte:head><title>Bank · Electric Cherry</title></svelte:head>
<div class="bank-page">
<SectionHeader
title="Bank"
eyebrow="Players"
subtitle="Shared player balances across Black Ops I & II."
>
{#snippet actions()}<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 30 s"
retryDisabled={refreshing || busy}
onRetry={() => void refresh()}
/><Button size="sm" loading={refreshing} disabled={busy} onclick={() => void refresh()}
>Refresh</Button
>{/snippet}
</SectionHeader>
<dl class="summary">
<div>
<dt>Total banked</dt>
<dd>{number.format(report.summary.totalBanked)} <small>pts</small></dd>
</div>
<div>
<dt>Funded accounts</dt>
<dd>{number.format(report.summary.fundedAccounts)}</dd>
</div>
<div>
<dt>Transfers · last 24 hours</dt>
<dd>{number.format(report.summary.transfers24h)}</dd>
</div>
<div>
<dt>Pending transfers</dt>
<dd>
<button class="summary-link" onclick={() => changeView('pending')}
>{number.format(report.summary.pending)}</button
>
</dd>
</div>
</dl>
{#if report.maximum === null}<p class="notice">
The bank deposit limit is unavailable. Balances and transfer history remain visible; new
deposits are disabled until the configuration is corrected.
</p>{/if}
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<Tabs
{tabs}
value={report.query.view}
onchange={changeView}
ariaLabel="Bank views"
panelId="bank-results"
idPrefix="bank-tab"
/>
<div
id="bank-results"
role="tabpanel"
aria-labelledby={`bank-tab-${report.query.view}`}
aria-busy={busy}
class="results"
>
<form class="filters" method="GET" action="/bank" onsubmit={applyFilters}>
<input type="hidden" name="view" value={report.query.view} />
<div class="search">
<Field label="Player name or GUID"
><Input
name="q"
type="search"
bind:value={filters.q}
maxlength={100}
placeholder="Search players…"
/></Field
>
</div>
{#if report.query.view === 'accounts'}
<Field label="Accounts"
><Select
name="balance"
bind:value={filters.balance}
options={[
{ value: '', label: 'All accounts' },
{ value: 'funded', label: 'Funded accounts' },
{
value: 'limit',
label: 'At or above limit',
disabled: report.maximum === null
},
{ value: 'pending', label: 'Pending transfers' }
]}
/></Field
>
<Field label="Sort by"
><Select
name="sort"
bind:value={filters.sort}
options={[
{ value: 'balance', label: 'Highest balance' },
{ value: 'recent', label: 'Recent activity' }
]}
/></Field
>
{:else}
<Field label="Game"
><Select
name="game"
bind:value={filters.game}
options={[
{ value: '', label: 'All games' },
{ value: 't5', label: 'Black Ops I' },
{ value: 't6', label: 'Black Ops II' }
]}
/></Field
>
<Field label="Server"
><Select
name="server"
bind:value={filters.server}
options={serverOptions}
/></Field
>
<Field label="Transfer"
><Select
name="operation"
bind:value={filters.operation}
options={[
{ value: '', label: 'All transfers' },
{ value: 'deposit', label: 'Deposits' },
{ value: 'withdraw', label: 'Withdrawals' }
]}
/></Field
>
<Field label="From (UTC)"
><Input
name="from"
type="date"
bind:value={filters.from}
max={filters.to || undefined}
/></Field
>
<Field label="Through (UTC)"
><Input
name="to"
type="date"
bind:value={filters.to}
min={filters.from || undefined}
/></Field
>
{/if}
<div class="filter-actions">
<Button type="submit" disabled={busy}>Apply</Button><Button
variant="ghost"
disabled={busy}
onclick={() => {
void navigate({
q: null,
balance: null,
sort: null,
game: null,
server: null,
operation: null,
from: null,
to: null,
offset: null
});
}}>Reset</Button
>
</div>
</form>
{#if report.query.view === 'accounts'}
<p class="hint">
Balances are shared across both games.{report.maximum !== null
? ` Account limit: ${number.format(report.maximum)} points.`
: ''}
</p>
{#if report.accounts.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 accounts"
tabindex="0"
>
<table class="bank-table">
<caption class="sr-only">Player bank accounts</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Balance</th><th
scope="col">Bank capacity</th
><th scope="col">Last activity</th><th scope="col">Status</th></tr
></thead
>
<tbody
>{#each report.accounts.entries as account (account.guid)}
<tr>
<td
><button
class="bank-player"
onclick={() => openPlayer(account.guid)}
>{account.name ?? account.guid}</button
><small class="bank-id">{account.guid}</small></td
>
<td class="bank-number"
><strong>{number.format(account.balance)} pts</strong
>{#if account.available !== account.balance}<small
>{number.format(account.available)} available</small
>{/if}</td
>
<td
>{#if report.maximum !== null}<div class="capacity">
<meter
min="0"
max={report.maximum}
value={Math.min(
account.balance,
report.maximum
)}
aria-label={`${account.name ?? account.guid} bank capacity`}
></meter><span>{capacity(account.balance)}%</span>
</div>{:else}Unavailable{/if}</td
>
<td
>{#if account.lastActivity}<span
>{(account.lastAmount ?? 0) > 0
? 'Deposit'
: 'Withdrawal'}</span
><small
><RelativeTime
value={account.lastActivity}
/></small
>{:else}No completed transfers{/if}</td
>
<td
>{#if account.pendingOperation}<span
>Pending {account.pendingOperation === 'deposit'
? 'deposit'
: 'withdrawal'}</span
><small
>{number.format(account.pendingAmount ?? 0)} pts</small
>{:else if report.maximum !== null && account.balance >= report.maximum}At
limit{:else}Available{/if}</td
>
</tr>
{/each}</tbody
>
</table>
</div>
{:else}<EmptyState
title="No bank accounts found"
description="Accounts appear when players deposit points in game. Try changing your filters if you expected to see more."
/>{/if}
{:else if report.query.view === 'transactions'}
<p class="hint">
Completed deposits and withdrawals across your fleet. Game filters apply to
activity; account balances remain shared.
</p>
<BankTransfers
entries={report.transactions.entries}
source={$page.url}
onPlayer={openPlayer}
/>
{:else}
<p class="hint">
Oldest transfers first. Transfers waiting at least five minutes are highlighted for
attention.
</p>
{#if report.pending.entries.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div
class="bank-table-scroll"
role="region"
aria-label="Pending bank transfers"
tabindex="0"
>
<table class="bank-table">
<caption class="sr-only">Transfers awaiting settlement</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Transfer</th><th
scope="col">Server</th
><th scope="col">Waiting since</th><th scope="col">Details</th></tr
></thead
>
<tbody
>{#each report.pending.entries as transfer (transfer.guid)}
<tr>
<td
><button
class="bank-player"
onclick={() => openPlayer(transfer.guid)}
>{transfer.name ?? transfer.guid}</button
><small class="bank-id">{transfer.guid}</small></td
>
<td class="bank-number"
><strong>{number.format(transfer.amount)} pts</strong><small
>{transfer.operation === 'deposit'
? 'Deposit'
: 'Withdrawal'}</small
></td
>
<td><ServerIdentity name={transfer.server} /></td>
<td
><RelativeTime
value={transfer.createdAt}
/>{#if isOlder(transfer.createdAt)}<small
class="bank-warning">Waiting over 5 minutes</small
>{/if}</td
>
<td
><details>
<summary>Why pending?</summary>
<div class="pending-expanded">
<BankPendingDetails {transfer} source={$page.url} />
</div>
</details></td
>
</tr>
{/each}</tbody
>
</table>
</div>
{:else}<EmptyState
title="No pending transfers"
description="No transfers are waiting for settlement in this view."
/>{/if}
{/if}
<BankPagination
total={results.total}
offset={results.offset}
disabled={busy}
onchange={(offset) => {
void navigate({ offset });
}}
label={report.query.view === 'accounts' ? 'accounts' : 'transfers'}
/>
</div>
</div>
<Dialog
drawer
bind:open={() => !!report.query.player, setDrawerOpen}
title={report.detail?.account.name ?? (report.query.player || 'Player bank')}
description="Shared bank account across Black Ops I & II."
>
{#if report.detail}
{@const detail = report.detail}
<div class="account-detail">
<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 30 s"
retryDisabled={refreshing || busy}
onRetry={() => void refresh()}
/>
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<CopyValue value={detail.account.guid} label="Player GUID" />
<dl class="detail-summary">
<div>
<dt>Balance</dt>
<dd>{number.format(detail.account.balance)} pts</dd>
</div>
<div>
<dt>Available</dt>
<dd>{number.format(detail.account.available)} pts</dd>
</div>
</dl>
{#if detail.account.name}<Button
size="sm"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(detail.account.guid)}`,
$page.url
)}>View player profile</Button
>{/if}
{#if detail.pending}<section class="pending-card">
<h3>
Pending {detail.pending.operation === 'deposit' ? 'deposit' : 'withdrawal'} ·
{number.format(detail.pending.amount)} pts
</h3>
<BankPendingDetails transfer={detail.pending} source={$page.url} />
</section>{/if}
<h3>Transaction history</h3>
<BankTransfers entries={detail.history.entries} source={$page.url} compact />
<BankPagination
total={detail.history.total}
offset={detail.history.offset}
disabled={busy}
onchange={(historyOffset) => {
void navigate({ historyOffset });
}}
label="player transfers"
/>
</div>
{:else}<EmptyState
title="Player bank not found"
description="There is no bank or player record for this GUID."
/>{/if}
{#snippet actions()}<Button onclick={() => setDrawerOpen(false)}>Close</Button>{/snippet}
</Dialog>
<style>
.bank-page,
.results,
.account-detail {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
margin: 0;
}
.summary > div {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
dt {
color: var(--text-secondary);
font-size: var(--text-sm);
}
dd {
margin: var(--space-2) 0 0;
font-size: var(--text-2xl);
font-weight: var(--weight-semibold);
font-variant-numeric: tabular-nums;
}
dd small {
font-size: var(--text-sm);
font-weight: var(--weight-regular);
color: var(--text-secondary);
}
.summary-link {
padding: 0;
border: 0;
background: none;
color: var(--accent-primary-hover);
font: inherit;
cursor: pointer;
}
.filters {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
.search {
flex: 1 1 240px;
}
.filter-actions {
display: flex;
gap: var(--space-2);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.capacity {
display: flex;
align-items: center;
gap: var(--space-2);
font-variant-numeric: tabular-nums;
}
meter {
width: 100px;
height: 12px;
accent-color: var(--text-secondary);
}
meter::-webkit-meter-bar {
background: var(--surface-raised);
border: 0;
}
meter::-webkit-meter-optimum-value {
background: var(--text-secondary);
}
meter::-moz-meter-bar {
background: var(--text-secondary);
}
summary {
color: var(--accent-primary-hover);
white-space: nowrap;
}
.pending-expanded {
min-width: 250px;
max-width: 380px;
padding-top: var(--space-3);
}
.detail-summary {
display: flex;
flex-wrap: wrap;
gap: var(--space-6);
margin: 0;
}
.detail-summary dd {
font-size: var(--text-xl);
}
h3 {
margin: 0;
font-size: var(--text-md);
}
.pending-card {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
}
.notice {
margin: 0;
padding: var(--space-4);
border: 1px solid var(--state-warning);
border-radius: var(--radius-md);
color: var(--state-warning);
font-size: var(--text-sm);
}
@media (max-width: 1100px) {
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.summary {
gap: var(--space-2);
}
.summary > div {
padding: var(--space-3);
}
dd {
font-size: var(--text-xl);
}
}
</style>
@@ -164,8 +164,13 @@
description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute." description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute."
/> />
{/if} {/if}
<section aria-labelledby="bank-title"> <section class="bank-card" aria-labelledby="bank-title">
<div class="bank-heading">
<h2 id="bank-title">Bank</h2> <h2 id="bank-title">Bank</h2>
<Button size="sm" href={`/bank?player=${encodeURIComponent(profile.guid)}`}
>View bank history</Button
>
</div>
<p> <p>
<strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II. <strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II.
</p> </p>
@@ -184,7 +189,7 @@
></thead ></thead
> >
<tbody <tbody
>{#each data.bank.entries as entry (entry.id)} >{#each data.bank.entries.slice(0, 3) as entry (entry.id)}
<tr <tr
><td>{$timeFormat.dateTime(entry.createdAt)}</td><td ><td>{$timeFormat.dateTime(entry.createdAt)}</td><td
>{serverTitle(entry.server)}</td >{serverTitle(entry.server)}</td
@@ -499,6 +504,22 @@
</Dialog> </Dialog>
<style> <style>
.bank-card {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.bank-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
.bank-heading h2 {
margin: 0;
}
.profile { .profile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+217
View File
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import BankPage from '../../src/routes/bank/+page.svelte';
import { readBankQuery, type BankOverview } from '../../src/lib/bank.js';
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
const currentPage = writable({ url: new URL('http://localhost/bank'), data: { user: null } });
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
},
navigating: writable(null)
}));
vi.mock('$app/navigation', () => ({
goto: navigation.goto,
beforeNavigate: (fn: () => void) => navigation.before.push(fn),
afterNavigate: vi.fn()
}));
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
navigation.goto.mockReset().mockResolvedValue(undefined);
navigation.before.length = 0;
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const account = {
guid: '18446744073709551615',
name: 'Dempsey',
balance: 2000,
available: 1500,
pendingOperation: 'withdraw' as const,
pendingAmount: 500,
lastActivity: '2026-10-13T11:00:00.000Z',
lastAmount: 2000
};
const transfer = {
id: 1,
guid: account.guid,
name: account.name,
amount: 2000,
balance: 2000,
server: 't5_zombie_theater_1',
game: 't5',
matchId: 17,
matchAvailable: 1,
createdAt: account.lastActivity
};
function report(search = ''): BankOverview {
const query = readBankQuery(new URLSearchParams(search));
return {
query,
maximum: 250000,
checkedAt: '2026-10-13T12:00:00.000Z',
summary: { totalBanked: 2000, fundedAccounts: 1, transfers24h: 1, pending: 1 },
servers: [transfer.server],
accounts: { entries: [account], total: 1, offset: 0 },
transactions: { entries: [transfer], total: 1, offset: 0 },
pending: {
entries: [{ ...transfer, requestId: 'pending-id', amount: 500, operation: 'withdraw' }],
total: 1,
offset: 0
},
detail: query.player
? {
account,
pending: null,
history: { entries: [transfer], total: 30, offset: query.historyOffset }
}
: null
};
}
function show(initial = report()) {
const data = writable({ report: initial, user: null, setupRequired: false });
const state = fromStore(data);
currentPage.set({
url: new URL(
`http://localhost/bank?${new URLSearchParams(Object.entries(initial.query).map(([key, value]) => [key, String(value)]))}`
),
data: { user: null }
});
component = mount(BankPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: BankOverview) => {
currentPage.set({
url: new URL(
`http://localhost/bank?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}`
),
data: { user: null }
});
data.set({ report: next, user: null, setupRequired: false });
flushSync();
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const lastNavigation = () => new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
it('opens player details without losing list filters and restores the list when closed', async () => {
const update = show(report('q=Dempsey&sort=recent&offset=25'));
button('Dempsey').click();
expect(lastNavigation().searchParams.get('player')).toBe(account.guid);
expect(lastNavigation().searchParams.get('q')).toBe('Dempsey');
expect(lastNavigation().searchParams.get('offset')).toBe('25');
update(report(`q=Dempsey&sort=recent&offset=25&player=${account.guid}`));
await tick();
expect(document.querySelector('dialog')?.open).toBe(true);
expect(document.querySelector('dialog')?.textContent).toContain('1,500 pts');
const profile = [...document.querySelectorAll('dialog a')].find((link) =>
link.textContent?.includes('View player profile')
)!;
const target = new URL(profile.getAttribute('href')!, 'http://localhost');
expect(target.searchParams.get('returnTo')).toContain(`player=${account.guid}`);
button('Next').click();
expect(lastNavigation().searchParams.get('historyOffset')).toBe('25');
expect(lastNavigation().searchParams.get('offset')).toBe('25');
button('Close').click();
expect(lastNavigation().searchParams.has('player')).toBe(false);
expect(lastNavigation().searchParams.get('sort')).toBe('recent');
});
it('applies accessible transfer filters and resets pagination while preserving player detail', () => {
show(report(`view=transactions&offset=25&player=${account.guid}`));
const search = document.querySelector<HTMLInputElement>('input[name="q"]')!;
search.value = 'Nikolai';
search.dispatchEvent(new Event('input', { bubbles: true }));
const game = document.querySelector<HTMLSelectElement>('select[name="game"]')!;
// happy-dom's :checked selector only matches inputs, not selected options.
const selectQuery = game.querySelector.bind(game);
game.querySelector = ((selector: string) =>
selector === ':checked'
? (game.selectedOptions[0] ?? null)
: selectQuery(selector)) as typeof game.querySelector;
game.value = 't6';
game.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
expect(lastNavigation().searchParams.get('q')).toBe('Nikolai');
expect(lastNavigation().searchParams.get('game')).toBe('t6');
expect(lastNavigation().searchParams.has('offset')).toBe(false);
expect(lastNavigation().searchParams.get('player')).toBe(account.guid);
button('Reset').click();
expect(lastNavigation().searchParams.has('game')).toBe(false);
});
it('shows pending reservation guidance, age and historical matches without dead links', () => {
const initial = report('view=pending');
initial.pending.entries[0].matchAvailable = 0;
show(initial);
const panel = document.querySelector('#bank-results')!;
expect(panel.textContent).toContain('Waiting over 5 minutes');
expect(panel.textContent).toContain('These points are reserved');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
expect(panel.querySelector('details summary')?.textContent).toBe('Why pending?');
});
it('retains data on refresh errors and successfully retries without erasing draft filters', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(new Response(JSON.stringify(report())));
vi.stubGlobal('fetch', fetch);
show();
const search = document.querySelector<HTMLInputElement>('input[name="q"]')!;
search.value = 'draft';
search.dispatchEvent(new Event('input', { bubbles: true }));
button('Refresh').click();
await vi.waitFor(() => expect(document.body.textContent).toContain('Bank updates paused.'));
expect(document.querySelector('#bank-results')?.textContent).toContain('Dempsey');
button('Retry').click();
await vi.waitFor(() => expect(document.body.textContent).not.toContain('Bank updates paused.'));
expect(search.value).toBe('draft');
});
it('discards an old refresh when navigation changes the bank view', async () => {
let resolve!: (response: Response) => void;
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise<Response>((done) => {
resolve = done;
})
)
);
const update = show();
button('Refresh').click();
navigation.before.forEach((fn) => fn());
const next = report('view=transactions');
next.summary.totalBanked = 9000;
update(next);
resolve(new Response(JSON.stringify(report())));
await tick();
await tick();
expect(document.querySelector('.summary')?.textContent).toContain('9,000');
expect(document.querySelector('#bank-tab-transactions')?.getAttribute('aria-selected')).toBe(
'true'
);
expect(document.querySelector('#bank-results')?.textContent).toContain('Balance after');
});
+2
View File
@@ -55,6 +55,8 @@ describe('management route permissions', () => {
'/api/servers/[name]/scripts', '/api/servers/[name]/scripts',
'/api/metrics', '/api/metrics',
'/metrics', '/metrics',
'/bank',
'/api/bank',
'/api/matches', '/api/matches',
'/matches/[id]', '/matches/[id]',
'/leaderboard/players/[game]/[guid]', '/leaderboard/players/[game]/[guid]',
+303
View File
@@ -0,0 +1,303 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import { readBankQuery, type BankQuery } from '../../src/lib/bank.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
let overview: typeof import('../../src/lib/server/fleet/bank-overview.js').bankOverview;
const now = new Date('2026-10-13T12:00:00.000Z');
const server = 't5_zombie_theater_1';
const other = 't6_zm_transit_1';
const guid = '18446744073709551615';
const query = (changes: Partial<BankQuery> = {}) => ({
...readBankQuery(new URLSearchParams()),
...changes
});
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
vi.stubEnv('EC_BANK_MAX_BALANCE', '250000');
handle = (await import('../../src/lib/server/db/index.js')).db();
overview = (await import('../../src/lib/server/fleet/bank-overview.js')).bankOverview;
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
function identity(id = guid, name = 'Dempsey', game = 't5', seen = '2026-10-13T10:00:00.000Z') {
handle
.prepare(
'INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES (?,?,?,?,?)'
)
.run(game, id, name, seen, seen);
}
function fund(id = guid, balance = 2000) {
handle.prepare('INSERT INTO bank_accounts(guid,balance) VALUES (?,?)').run(id, balance);
}
function transfer(
id = guid,
amount = 2000,
source = server,
createdAt = now.toISOString(),
matchId: number | null = null
) {
return handle
.prepare(
'INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)'
)
.run(id, source, `ledger-${serial++}`, matchId, amount, 2000, createdAt).lastInsertRowid;
}
let serial = 0;
function prepare(
id = guid,
operation = 'withdraw',
amount = 500,
createdAt = '2026-10-13T11:50:00.000Z'
) {
handle
.prepare(
`INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,amount,state,result,created_at)
VALUES (?,?,?,'private-fingerprint',?,?,'prepared','private-result',?)`
)
.run(server, `pending-${serial++}`, id, operation, amount, createdAt);
}
it('deduplicates game identities, preserves exact GUIDs and exposes reserved withdrawal funds', () => {
identity();
identity(guid, 'Latest name', 't6', '2026-10-13T11:00:00.000Z');
identity('00042', 'Leading zero');
identity('42', 'Separate account');
fund();
fund('00042', 100);
fund('42', 0);
transfer();
prepare();
const report = overview(query({ player: guid }), now);
expect(report.accounts.total).toBe(3);
expect(report.accounts.entries[0]).toMatchObject({
guid,
name: 'Latest name',
balance: 2000,
available: 1500,
pendingAmount: 500
});
expect(report.detail?.account).toEqual(report.accounts.entries[0]);
expect(report.summary).toEqual({
totalBanked: 2100,
fundedAccounts: 2,
transfers24h: 1,
pending: 1
});
expect(overview(query({ player: '42' }), now).detail?.account.balance).toBe(0);
expect(overview(query({ player: '00042' }), now).detail?.account.balance).toBe(100);
});
it('includes first deposits awaiting settlement without crediting them to balances', () => {
identity();
prepare(guid, 'deposit', 5000);
const report = overview(query({ balance: 'pending', player: guid }), now);
expect(report.accounts.entries).toHaveLength(1);
expect(report.accounts.entries[0]).toMatchObject({
balance: 0,
available: 0,
pendingOperation: 'deposit',
lastActivity: null
});
expect(report.summary).toMatchObject({
totalBanked: 0,
fundedAccounts: 0,
pending: 1,
transfers24h: 0
});
expect(report.detail?.pending).toMatchObject({ amount: 5000, operation: 'deposit' });
expect(report.detail?.history.total).toBe(0);
});
it('filters and sorts accounts with literal searches and keeps summaries fleet-wide', () => {
identity();
identity('other', '100%_Player');
fund();
fund('other', 250000);
transfer(guid, 2000, server, '2026-10-13T11:00:00.000Z');
transfer('other', 1000, other, '2026-10-12T10:00:00.000Z');
expect(overview(query({ q: '%_' }), now).accounts.entries.map((row) => row.guid)).toEqual([
'other'
]);
expect(overview(query({ q: "' OR 1=1 --" }), now).accounts.total).toBe(0);
expect(overview(query({ q: 'dEmP' }), now).accounts.entries[0].guid).toBe(guid);
expect(
overview(query({ balance: 'limit' }), now).accounts.entries.map((row) => row.guid)
).toEqual(['other']);
expect(
overview(query({ sort: 'recent' }), now).accounts.entries.map((row) => row.guid)
).toEqual([guid, 'other']);
expect(overview(query({ q: 'not-found' }), now).summary).toMatchObject({
totalBanked: 252000,
transfers24h: 1
});
});
it('filters transfer activity by game, server, direction and inclusive UTC dates without narrowing player balances', () => {
identity();
fund();
transfer(guid, 3000, server, '2026-10-12T23:59:59.999Z');
transfer(guid, -1000, other, '2026-10-13T00:00:00.000Z');
transfer(guid, 500, other, '2026-10-13T23:59:59.999Z');
transfer(guid, 100, other, '2026-10-14T00:00:00.000Z');
const report = overview(
query({
view: 'transactions',
game: 't6',
server: other,
from: '2026-10-13',
to: '2026-10-13',
player: guid
}),
now
);
expect(report.transactions.entries.map((row) => row.amount)).toEqual([500, -1000]);
expect(report.detail?.account.balance).toBe(2000);
expect(report.detail?.history.total).toBe(4);
expect(
overview(
query({ view: 'transactions', operation: 'withdraw' }),
now
).transactions.entries.map((row) => row.amount)
).toEqual([-1000]);
expect(overview(query({ view: 'transactions', game: 't5' }), now).transactions.total).toBe(1);
expect(
overview(query({ view: 'transactions', server: 'absent' }), now).transactions.total
).toBe(0);
});
it('keeps historical transfers searchable after match and player records are removed', () => {
identity();
fund();
const matchId = Number(
handle
.prepare(
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES (?,'t5','theater','theater','Kino',?,?,'game_start','fingerprint')`
)
.run(server, now.toISOString(), now.toISOString()).lastInsertRowid
);
transfer(guid, 2000, server, now.toISOString(), matchId);
expect(
overview(query({ view: 'transactions' }), now).transactions.entries[0].matchAvailable
).toBe(1);
handle.prepare('DELETE FROM leaderboard_matches WHERE id = ?').run(matchId);
handle.prepare('DELETE FROM leaderboard_players WHERE guid = ?').run(guid);
const report = overview(
query({ view: 'transactions', game: 't5', q: guid, player: guid }),
now
);
expect(report.transactions.entries[0]).toMatchObject({
guid,
name: null,
matchId,
matchAvailable: 0,
game: 't5'
});
expect(report.detail?.account.balance).toBe(2000);
expect(report.servers).toEqual([server]);
});
it('pages accounts and player history independently with stable ties and clamps stale offsets', () => {
for (let i = 0; i < 31; i++) {
fund(String(i).padStart(2, '0'), 100);
transfer('00', 100);
}
const first = overview(query({ player: '00' }), now);
const second = overview(query({ offset: 999999, player: '00', historyOffset: 999999 }), now);
expect(first.accounts.entries).toHaveLength(25);
expect(second.accounts).toMatchObject({ total: 31, offset: 25 });
expect(second.accounts.entries).toHaveLength(6);
expect(
new Set([...first.accounts.entries, ...second.accounts.entries].map((row) => row.guid)).size
).toBe(31);
expect(second.detail?.history).toMatchObject({ total: 31, offset: 25 });
expect(
new Set(
[...first.detail!.history.entries, ...second.detail!.history.entries].map(
(row) => row.id
)
).size
).toBe(31);
});
it('orders pending transfers oldest first and excludes cancelled requests and private request data', () => {
prepare('new', 'deposit', 100, '2026-10-13T11:59:00.000Z');
prepare('old', 'withdraw', 200, '2026-10-12T11:00:00.000Z');
prepare('cancelled');
handle.prepare("UPDATE bank_requests SET state = 'cancelled' WHERE guid = 'cancelled'").run();
const report = overview(query({ view: 'pending' }), now);
expect(report.pending.entries.map((row) => row.guid)).toEqual(['old', 'new']);
expect(
overview(query({ view: 'pending', operation: 'deposit' }), now).pending.entries.map(
(row) => row.guid
)
).toEqual(['new']);
expect(JSON.stringify(report)).not.toMatch(
/private-fingerprint|private-result|instance_id|token/
);
});
it('keeps existing accounts readable when the deposit limit changes or becomes invalid', () => {
fund(guid, 250000);
prepare();
vi.stubEnv('EC_BANK_MAX_BALANCE', '100000');
expect(overview(query({ balance: 'limit' }), now).accounts.total).toBe(1);
vi.stubEnv('EC_BANK_MAX_BALANCE', 'invalid');
const report = overview(query({ player: guid }), now);
expect(report.maximum).toBeNull();
expect(report.detail?.account).toMatchObject({ balance: 250000, available: 249500 });
expect(report.summary.pending).toBe(1);
});
it('shows known players with no bank history, distinguishes unknown GUIDs, and serves uncached reads', async () => {
identity();
expect(overview(query({ player: guid }), now).detail?.account.balance).toBe(0);
expect(overview(query({ player: 'unknown' }), now).detail).toBeNull();
const { GET } = await import('../../src/routes/api/bank/+server.js');
const { load } = await import('../../src/routes/bank/+page.server.js');
const url = new URL(`https://test/bank?player=${guid}`);
const response = GET({ url });
expect(response.headers.get('cache-control')).toBe('no-store');
expect((await response.json()).detail.account.guid).toBe(guid);
expect(load({ url }).report.detail?.account.guid).toBe(guid);
for (const invalid of ['view=unknown', 'offset=-1', 'from=2026-02-30']) {
expect(() => GET({ url: new URL(`https://test/api/bank?${invalid}`) })).toThrow(
expect.objectContaining({ status: 400 })
);
expect(() => load({ url: new URL(`https://test/bank?${invalid}`) })).toThrow(
expect.objectContaining({ status: 400 })
);
}
});
describe('bank query validation', () => {
it.each([
'view=nope',
'balance=nope',
'game=t7',
'sort=nope',
'operation=commit',
'offset=NaN',
'offset=1.5',
'historyOffset=Infinity',
'from=2026-02-30',
'to=2026-13-01',
'from=2026-10-14&to=2026-10-13',
`q=${'a'.repeat(101)}`
])('rejects %s', (value) => {
expect(() => readBankQuery(new URLSearchParams(value))).toThrow();
});
it('retains string identities and normalizes page boundaries', () => {
expect(
readBankQuery(new URLSearchParams(`player=${guid}&offset=27&historyOffset=51`))
).toMatchObject({ player: guid, offset: 25, historyOffset: 50 });
});
});
+6
View File
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest';
import { contextHref, returnLabel, safeReturnHref } from '../../src/lib/navigation-context.js'; import { contextHref, returnLabel, safeReturnHref } from '../../src/lib/navigation-context.js';
describe('navigation context', () => { describe('navigation context', () => {
it('restores Bank filters, pagination and the selected player from detail pages', () => {
const source = '/bank?view=transactions&game=t5&offset=25&player=00042&historyOffset=25';
const target = new URL(contextHref('/matches/17', source), 'http://localhost');
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to Bank');
});
it('retains nested origins, list scope, loaded results and anchors', () => { it('retains nested origins, list scope, loaded results and anchors', () => {
const source = new URL( const source = new URL(
'http://localhost/leaderboard/players/42?game=t5&offset=25&returnTo=%2Fleaderboard%3Fq%3DAce#history-title' 'http://localhost/leaderboard/players/42?game=t5&offset=25&returnTo=%2Fleaderboard%3Fq%3DAce#history-title'
+1
View File
@@ -20,6 +20,7 @@ describe('application navigation', () => {
['/campaigns/42', 'releases', '/campaigns'], ['/campaigns/42', 'releases', '/campaigns'],
['/configs/42', 'releases', '/configs'], ['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'], ['/leaderboard/players/42', 'players', '/leaderboard'],
['/bank', 'players', '/bank'],
['/settings/users', 'admin', '/settings/users'], ['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys'] ['/server-keys', 'admin', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => { ])('gives %s exactly one canonical home', (path, group, href) => {
+1
View File
@@ -33,6 +33,7 @@ describe('sidebar navigation', () => {
expect(body).toContain('href="/betas"'); expect(body).toContain('href="/betas"');
expect(body).toContain('href="/activity"'); expect(body).toContain('href="/activity"');
expect(body).toContain('href="/metrics"'); expect(body).toContain('href="/metrics"');
expect(body).toContain('href="/bank"');
expect(body).not.toContain('href="/settings"'); expect(body).not.toContain('href="/settings"');
expect(body).not.toContain('href="/server-keys"'); expect(body).not.toContain('href="/server-keys"');
}); });