This commit is contained in:
NextJS
2026-05-11 04:30:39 +00:00
parent 0f6e979aac
commit b2be61190f
13 changed files with 1361 additions and 6 deletions
+13
View File
@@ -0,0 +1,13 @@
create table public.transactions (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
"from" bigint null,
"to" bigint null,
amount bigint not null default '4'::bigint,
remarks character varying null,
match_id bigint null,
constraint transactions_pkey primary key (id),
constraint transactions_from_fkey foreign KEY ("from") references users (id),
constraint transactions_match_id_fkey foreign KEY (match_id) references matches (id),
constraint transactions_to_fkey foreign KEY ("to") references users (id)
) TABLESPACE pg_default;
+9
View File
@@ -0,0 +1,9 @@
id,created_at,from,to,amount,remarks,match_id
1,2026-05-07 15:44:58.257397+00,,1,4000000000,supply,
12,2026-05-07 20:15:23.039708+00,1,5,80,purchase,
13,2026-05-07 20:16:12.295235+00,1,4,80,purchase,
14,2026-05-07 20:17:18.019151+00,4,1,18,entry_hold,266
15,2026-05-07 20:17:18.019151+00,4,1,3,entry_fee,266
16,2026-05-07 20:17:18.019151+00,5,1,18,entry_hold,266
17,2026-05-07 20:17:18.019151+00,5,1,3,entry_fee,266
18,2026-05-07 20:17:18.019151+00,1,5,36,reward,266
1 id created_at from to amount remarks match_id
2 1 2026-05-07 15:44:58.257397+00 1 4000000000 supply
3 12 2026-05-07 20:15:23.039708+00 1 5 80 purchase
4 13 2026-05-07 20:16:12.295235+00 1 4 80 purchase
5 14 2026-05-07 20:17:18.019151+00 4 1 18 entry_hold 266
6 15 2026-05-07 20:17:18.019151+00 4 1 3 entry_fee 266
7 16 2026-05-07 20:17:18.019151+00 5 1 18 entry_hold 266
8 17 2026-05-07 20:17:18.019151+00 5 1 3 entry_fee 266
9 18 2026-05-07 20:17:18.019151+00 1 5 36 reward 266
+2
View File
@@ -32,6 +32,8 @@ export async function updateUserCcRc(formData: FormData) {
? "players"
: tabRaw === "matchmaker"
? "matchmaker"
: tabRaw === "ledger"
? "ledger"
: "dashboard";
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
+431
View File
@@ -0,0 +1,431 @@
import { Fragment } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AdminHeader } from "@/components/admin-header";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import {
formatRcDecimalFromCoinsBigInt,
formatRcLabelFromCoinsBigInt,
formatSignedRcFromCoinsBigInt,
} from "@/lib/coins-rc";
import { auditMatchEconomics } from "@/lib/ledger-match-audit";
import { buildLedgerBookPairs } from "@/lib/ledger-book-lines";
import { createAdminSupabase } from "@/lib/supabase/admin";
import type { DbTransaction } from "@/types/database";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return {
title: "Ledger book · Kick Kings Admin",
};
}
function firstSearchParam(
v: string | string[] | undefined,
): string | null {
if (v === undefined) return null;
if (typeof v === "string") return v;
return v[0] ?? null;
}
/** Previous calendar month in UTC, as inclusive YYYY-MM-DD bounds. */
function utcLastMonthDateRange(): { from: string; to: string } {
const now = new Date();
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
const yearLast = m === 0 ? y - 1 : y;
const monthLast = m === 0 ? 11 : m - 1;
const fromDate = new Date(Date.UTC(yearLast, monthLast, 1));
const toDate = new Date(Date.UTC(yearLast, monthLast + 1, 0));
const from = fromDate.toISOString().slice(0, 10);
const to = toDate.toISOString().slice(0, 10);
return { from, to };
}
function parseIsoDateOnly(s: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const d = new Date(`${s}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
type UsernameRow = { id: number; username: string | null };
export default async function LedgerBookPage({
searchParams,
}: {
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const sp = await searchParams;
let fromStr = firstSearchParam(sp.from);
let toStr = firstSearchParam(sp.to);
const defaults = utcLastMonthDateRange();
if (!fromStr || !toStr) {
redirect(
`/ledger-book?from=${encodeURIComponent(defaults.from)}&to=${encodeURIComponent(defaults.to)}`,
);
}
const fromDate = parseIsoDateOnly(fromStr);
const toDate = parseIsoDateOnly(toStr);
if (!fromDate || !toDate) {
redirect(
`/ledger-book?from=${encodeURIComponent(defaults.from)}&to=${encodeURIComponent(defaults.to)}`,
);
}
if (fromStr > toStr) {
const t = fromStr;
fromStr = toStr;
toStr = t;
}
const rangeStartIso = `${fromStr}T00:00:00.000Z`;
const rangeEndIso = `${toStr}T23:59:59.999Z`;
const supabase = createAdminSupabase();
let configError: string | null = null;
let loadError: string | null = null;
let transactions: DbTransaction[] = [];
let userNameRows: UsernameRow[] = [];
if (!supabase) {
configError =
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
} else {
const [txRes, usersRes] = await Promise.all([
supabase
.from("transactions")
.select("id, created_at, from, to, amount, remarks, match_id")
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.order("created_at", { ascending: true })
.order("id", { ascending: true })
.limit(10000),
supabase
.from("users")
.select("id, username")
.limit(5000),
]);
if (txRes.error) {
loadError = txRes.error.message;
} else {
transactions = (txRes.data ?? []) as DbTransaction[];
}
if (usersRes.error && !loadError) {
loadError = usersRes.error.message;
} else if (!usersRes.error) {
userNameRows = (usersRes.data ?? []) as UsernameRow[];
}
}
const usernameById = new Map<number, string | null>();
for (const u of userNameRows) {
usernameById.set(u.id, u.username ?? null);
}
const book =
transactions.length === 0
? {
pairs: [] as ReturnType<typeof buildLedgerBookPairs>["pairs"],
totalDebitCoins: BigInt(0),
totalCreditCoins: BigInt(0),
balanced: true,
}
: buildLedgerBookPairs(transactions, usernameById);
const matchAudits = auditMatchEconomics(transactions);
const unbalancedMatches = matchAudits.filter((m) => !m.balanced);
const rewardDeltaTotalCoins = matchAudits.reduce(
(sum, m) => sum + m.rewardDeltaCoins,
BigInt(0),
);
return (
<div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950">
<AdminHeader />
<main className="flex-1 space-y-6 px-6 py-8">
<div className="mx-auto max-w-[1200px] space-y-2">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Ledger book
</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Double-entry presentation in{" "}
<span className="font-mono text-zinc-800 dark:text-zinc-200">RC</span>{" "}
(from on-chain coin units). Each transaction posts a debit then a
matching credit; running balance returns to{" "}
<span className="font-medium text-zinc-800 dark:text-zinc-200">
0.0
</span>{" "}
after every pair. Period totals must match for a balanced book.
</p>
</div>
<div className="mx-auto max-w-[1200px] rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form
className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end"
method="get"
action="/ledger-book"
>
<div>
<label
htmlFor="ledger-book-from"
className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
>
From (UTC)
</label>
<input
id="ledger-book-from"
name="from"
type="date"
defaultValue={fromStr}
className="mt-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div>
<label
htmlFor="ledger-book-to"
className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
>
To (UTC)
</label>
<input
id="ledger-book-to"
name="to"
type="date"
defaultValue={toStr}
className="mt-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply range
</button>
<Link
href={`/ledger-book?from=${encodeURIComponent(defaults.from)}&to=${encodeURIComponent(defaults.to)}`}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset to last month (UTC)
</Link>
<Link
href="/"
className="text-sm font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
>
Dashboard
</Link>
</form>
<p className="mt-3 text-xs text-zinc-500 dark:text-zinc-400">
Inclusive UTC range: {rangeStartIso.slice(0, 10)} {" "}
{rangeEndIso.slice(0, 10)} · {transactions.length.toLocaleString("en-US")}{" "}
transactions loaded (cap 10,000).
</p>
</div>
{configError ? (
<div className="mx-auto max-w-[1200px] rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
{configError}
</div>
) : loadError ? (
<div className="mx-auto max-w-[1200px]">
<p className="text-sm text-red-600 dark:text-red-400">{loadError}</p>
</div>
) : (
<>
<div className="mx-auto flex max-w-[1200px] flex-wrap gap-3">
<div
className={[
"rounded-lg border px-4 py-3 text-sm shadow-sm",
book.balanced
? "border-emerald-200 bg-emerald-50 text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
: "border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100",
].join(" ")}
>
<span className="font-semibold">Period balance: </span>
{book.balanced ? (
<span>
Total debits = total credits (
{formatRcLabelFromCoinsBigInt(book.totalDebitCoins)} in
coin-derived RC) balanced
</span>
) : (
<span>
Mismatch debits{" "}
{formatRcLabelFromCoinsBigInt(book.totalDebitCoins)} vs credits{" "}
{formatRcLabelFromCoinsBigInt(book.totalCreditCoins)}
</span>
)}
</div>
<div
className={[
"rounded-lg border px-4 py-3 text-sm shadow-sm",
rewardDeltaTotalCoins === BigInt(0) &&
unbalancedMatches.length === 0
? "border-emerald-200 bg-emerald-50 text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
: "border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100",
].join(" ")}
>
<span className="font-semibold">Match economics: </span>
{rewardDeltaTotalCoins === BigInt(0) &&
unbalancedMatches.length === 0 ? (
<span>
reward equals entry_hold for all matches balanced
</span>
) : (
<span>
reward-entry_hold delta{" "}
<span className="font-mono">
{rewardDeltaTotalCoins > BigInt(0)
? `+${rewardDeltaTotalCoins.toString()}`
: rewardDeltaTotalCoins.toString()}
</span>{" "}
coins across {unbalancedMatches.length} unbalanced match
{unbalancedMatches.length === 1 ? "" : "es"}
</span>
)}
</div>
</div>
<div className="mx-auto max-w-[1200px] overflow-x-auto rounded-xl border-2 border-zinc-300 bg-white shadow-md dark:border-zinc-700 dark:bg-zinc-900">
<table className="min-w-full border-collapse text-left text-sm">
<thead>
<tr className="border-b-2 border-zinc-400 bg-zinc-100 text-zinc-800 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200">
<th className="whitespace-nowrap px-3 py-3 text-xs font-semibold uppercase tracking-wide">
Date
</th>
<th className="min-w-[220px] px-3 py-3 text-xs font-semibold uppercase tracking-wide">
Particulars
</th>
<th className="whitespace-nowrap px-3 py-3 text-xs font-semibold uppercase tracking-wide">
Folio / ref
</th>
<th className="whitespace-nowrap px-3 py-3 text-right text-xs font-semibold uppercase tracking-wide">
Debit (RC)
</th>
<th className="whitespace-nowrap px-3 py-3 text-right text-xs font-semibold uppercase tracking-wide">
Credit (RC)
</th>
<th className="whitespace-nowrap px-3 py-3 text-right text-xs font-semibold uppercase tracking-wide">
Balance (RC)
</th>
</tr>
</thead>
<tbody className="text-zinc-900 dark:text-zinc-100">
{book.pairs.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-10 text-center text-zinc-500 dark:text-zinc-400"
>
No transactions in this range.
</td>
</tr>
) : (
book.pairs.map((p, idx) => {
const rcAmt = formatRcDecimalFromCoinsBigInt(p.amountCoins);
const refCell =
p.matchId != null ? (
<span>
{p.refLabel}
<span className="text-zinc-400"> · </span>
<Link
href={`/match-logs/${p.matchId}`}
className="font-mono text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:text-sky-900 dark:text-sky-300"
>
M{p.matchId}
</Link>
</span>
) : (
<span className="font-mono">{p.refLabel}</span>
);
return (
<Fragment key={`${p.refLabel}-${idx}`}>
<tr className="border-b border-zinc-100 bg-white dark:border-zinc-800 dark:bg-zinc-900/50">
<td
rowSpan={2}
className="whitespace-nowrap border-r border-zinc-200 bg-zinc-50/80 px-3 py-2 align-top text-xs font-medium text-zinc-700 dark:border-zinc-800 dark:bg-zinc-800/40 dark:text-zinc-300"
>
{p.dateDisplay}
</td>
<td className="px-3 py-2 text-xs leading-snug">
{p.drParticulars}
</td>
<td
rowSpan={2}
className="border-x border-zinc-200 px-3 py-2 align-top text-xs dark:border-zinc-800"
>
{refCell}
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{rcAmt}
</td>
<td className="px-3 py-2 text-right font-mono text-xs text-zinc-400 tabular-nums dark:text-zinc-500">
</td>
<td className="px-3 py-2 text-right font-mono text-xs font-medium tabular-nums">
{formatSignedRcFromCoinsBigInt(
p.balanceAfterDebitCoins,
)}
</td>
</tr>
<tr className="border-b border-zinc-200 bg-zinc-50/50 dark:border-zinc-800 dark:bg-zinc-950/30">
<td className="px-3 py-2 text-xs leading-snug">
{p.crParticulars}
</td>
<td className="px-3 py-2 text-right font-mono text-xs text-zinc-400 tabular-nums dark:text-zinc-500">
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{rcAmt}
</td>
<td className="px-3 py-2 text-right font-mono text-xs font-semibold tabular-nums text-emerald-800 dark:text-emerald-300">
{formatSignedRcFromCoinsBigInt(
p.balanceAfterCreditCoins,
)}
</td>
</tr>
</Fragment>
);
})
)}
</tbody>
{book.pairs.length > 0 ? (
<tfoot>
<tr className="border-t-2 border-zinc-400 bg-zinc-100 font-semibold dark:border-zinc-600 dark:bg-zinc-800">
<td
colSpan={3}
className="px-3 py-3 text-xs uppercase tracking-wide text-zinc-700 dark:text-zinc-300"
>
Period totals
</td>
<td className="px-3 py-3 text-right font-mono text-sm tabular-nums text-zinc-900 dark:text-zinc-50">
{formatRcDecimalFromCoinsBigInt(book.totalDebitCoins)}
</td>
<td className="px-3 py-3 text-right font-mono text-sm tabular-nums text-zinc-900 dark:text-zinc-50">
{formatRcDecimalFromCoinsBigInt(book.totalCreditCoins)}
</td>
<td className="px-3 py-3 text-right font-mono text-sm tabular-nums text-emerald-800 dark:text-emerald-200">
{formatSignedRcFromCoinsBigInt(
book.totalDebitCoins - book.totalCreditCoins,
)}
</td>
</tr>
</tfoot>
) : null}
</table>
</div>
</>
)}
</main>
</div>
);
}
+20 -1
View File
@@ -9,7 +9,7 @@ import {
type MatchmakerLogSource,
} from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
import type { DbMatch, DbUser } from "@/types/database";
import type { DbMatch, DbTransaction, DbUser } from "@/types/database";
export const dynamic = "force-dynamic";
@@ -42,6 +42,8 @@ export default async function Home({
? "players"
: tabParam === "matchmaker"
? "matchmaker"
: tabParam === "ledger"
? "ledger"
: "dashboard";
const highlightId = firstSearchParam(sp.highlight);
const participantRaw = firstSearchParam(sp.participant);
@@ -56,6 +58,8 @@ export default async function Home({
let configError: string | null = null;
let usersError: string | null = null;
let matchesError: string | null = null;
let transactions: DbTransaction[] = [];
let transactionsError: string | null = null;
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
null;
@@ -102,6 +106,19 @@ export default async function Home({
}
statsBundle = await loadDashboardStatsBundle(supabase);
if (tab === "ledger") {
const txRes = await supabase
.from("transactions")
.select("id, created_at, from, to, amount, remarks, match_id")
.order("id", { ascending: false })
.limit(5000);
if (txRes.error) {
transactionsError = txRes.error.message;
} else {
transactions = (txRes.data ?? []) as DbTransaction[];
}
}
}
let editUser: DbUser | null = null;
@@ -128,6 +145,8 @@ export default async function Home({
matches={matches}
usersError={usersError}
matchesError={matchesError}
transactions={transactions}
transactionsError={transactionsError}
statsBundle={statsBundle}
tab={tab}
highlightId={highlightId}
+25 -1
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { AdminLedger } from "@/components/admin-ledger";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle } from "@/lib/dashboard-stats";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
@@ -10,7 +11,7 @@ import {
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type { DbMatch, DbUser } from "@/types/database";
import type { DbMatch, DbTransaction, DbUser } from "@/types/database";
/** Bigint columns often arrive as strings from PostgREST / JSON. */
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
@@ -58,6 +59,8 @@ type Props = {
matches: DbMatch[];
usersError: string | null;
matchesError: string | null;
transactions: DbTransaction[];
transactionsError: string | null;
statsBundle: DashboardStatsBundle | null;
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
tab: AdminDashboardTab;
@@ -96,6 +99,8 @@ export function AdminDashboard({
matches,
usersError,
matchesError,
transactions,
transactionsError,
statsBundle,
tab,
highlightId,
@@ -228,6 +233,17 @@ export function AdminDashboard({
>
Matchmaker
</Link>
<Link
href={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
})}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</Link>
<Link
href="/settings"
className={tabClass(false)}
@@ -485,6 +501,14 @@ export function AdminDashboard({
errorMessage={matchmakerError}
/>
</section>
) : tab === "ledger" ? (
<AdminLedger
transactions={transactions}
users={users}
error={transactionsError}
highlightId={highlightId}
participantRaw={participantRaw}
/>
) : (
<section className="space-y-3">
{participantId != null ? (
+560
View File
@@ -0,0 +1,560 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
import { auditMatchEconomics } from "@/lib/ledger-match-audit";
import type { DbTransaction, DbUser } from "@/types/database";
function formatTsUtc(value: string | null): string {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
} catch {
return value;
}
}
function txAmountToBigInt(raw: number | string): bigint {
if (typeof raw === "number") {
if (!Number.isFinite(raw)) return BigInt(0);
return BigInt(Math.trunc(raw));
}
const t = String(raw).trim();
if (t === "" || !/^-?\d+$/.test(t)) return BigInt(0);
try {
return BigInt(t);
} catch {
return BigInt(0);
}
}
function formatBigAmount(n: bigint): string {
return n.toLocaleString("en-US");
}
function remarkClass(remarks: string | null): string {
const r = (remarks ?? "").toLowerCase();
if (r === "supply") return "bg-emerald-100 text-emerald-900 dark:bg-emerald-950/70 dark:text-emerald-100";
if (r === "purchase")
return "bg-sky-100 text-sky-900 dark:bg-sky-950/70 dark:text-sky-100";
if (r === "entry_hold")
return "bg-amber-100 text-amber-950 dark:bg-amber-950/60 dark:text-amber-100";
if (r === "entry_fee")
return "bg-violet-100 text-violet-900 dark:bg-violet-950/60 dark:text-violet-100";
if (r === "reward")
return "bg-rose-100 text-rose-900 dark:bg-rose-950/60 dark:text-rose-100";
return "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200";
}
function isEntryFeeRemark(remarks: string | null): boolean {
return (remarks ?? "").toLowerCase().trim() === "entry_fee";
}
export type LedgerIntegrity = {
mintTotal: bigint;
/** Outflows with no payee (kept for internal checks only). */
sinkTotal: bigint;
entryFeeCoinsTotal: bigint;
purchaseCoinsTotal: bigint;
entryHoldCoinsTotal: bigint;
rewardCoinsTotal: bigint;
sumUserNets: bigint;
balanced: boolean;
totalDebitVolume: bigint;
totalCreditVolume: bigint;
volumeBalanced: boolean;
};
function computeIntegrity(rows: DbTransaction[]): LedgerIntegrity {
let mintTotal = BigInt(0);
let sinkTotal = BigInt(0);
let entryFeeCoinsTotal = BigInt(0);
let purchaseCoinsTotal = BigInt(0);
let entryHoldCoinsTotal = BigInt(0);
let rewardCoinsTotal = BigInt(0);
const net = new Map<number, bigint>();
let totalDebitVolume = BigInt(0);
let totalCreditVolume = BigInt(0);
for (const row of rows) {
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) continue;
if (isEntryFeeRemark(row.remarks)) {
entryFeeCoinsTotal += a;
}
const remark = (row.remarks ?? "").toLowerCase().trim();
if (remark === "purchase") purchaseCoinsTotal += a;
if (remark === "entry_hold") entryHoldCoinsTotal += a;
if (remark === "reward") rewardCoinsTotal += a;
if (row.from == null) {
mintTotal += a;
} else {
totalDebitVolume += a;
const prev = net.get(row.from) ?? BigInt(0);
net.set(row.from, prev - a);
}
if (row.to == null) {
sinkTotal += a;
} else {
totalCreditVolume += a;
const prev = net.get(row.to) ?? BigInt(0);
net.set(row.to, prev + a);
}
}
let sumUserNets = BigInt(0);
for (const v of net.values()) {
sumUserNets += v;
}
const netIssued = mintTotal - sinkTotal;
const balanced = sumUserNets === netIssued;
const volumeBalanced =
totalDebitVolume + mintTotal === totalCreditVolume + sinkTotal;
return {
mintTotal,
sinkTotal,
entryFeeCoinsTotal,
purchaseCoinsTotal,
entryHoldCoinsTotal,
rewardCoinsTotal,
sumUserNets,
balanced,
totalDebitVolume,
totalCreditVolume,
volumeBalanced,
};
}
type Props = {
transactions: DbTransaction[];
users: DbUser[];
error: string | null;
highlightId: string | null;
participantRaw: string | null;
};
export function AdminLedger({
transactions,
users,
error,
highlightId,
participantRaw,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
for (const u of users) {
m.set(u.id, u.username);
}
return m;
}, [users]);
const integrity = useMemo(
() => computeIntegrity(transactions),
[transactions],
);
const matchChecks = useMemo(
() => auditMatchEconomics(transactions),
[transactions],
);
const userNets = useMemo(() => {
const net = new Map<number, bigint>();
for (const row of transactions) {
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) continue;
if (row.from != null) {
net.set(row.from, (net.get(row.from) ?? BigInt(0)) - a);
}
if (row.to != null) {
net.set(row.to, (net.get(row.to) ?? BigInt(0)) + a);
}
}
return [...net.entries()]
.filter(([, v]) => v !== BigInt(0))
.sort((a, b) => {
const da = a[1] < BigInt(0) ? -a[1] : a[1];
const db = b[1] < BigInt(0) ? -b[1] : b[1];
if (da === db) return a[0] - b[0];
return db > da ? 1 : -1;
});
}, [transactions]);
const ledgerRowsChronological = useMemo(
() => [...transactions].sort((a, b) => a.id - b.id),
[transactions],
);
function userCell(id: number | null, role: "from" | "to") {
if (id == null) {
return (
<span className="text-zinc-400 italic dark:text-zinc-500">
{role === "from" ? "∅ mint" : "∅"}
</span>
);
}
const un = usernameById.get(id);
return (
<span className="font-mono text-xs">
<Link
href={buildDashboardHref({
tab: "players",
highlightId: String(id),
participantRaw,
})}
className="text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
{id}
</Link>
{un != null && un !== "" ? (
<span className="text-zinc-600 dark:text-zinc-400"> ({un})</span>
) : null}
</span>
);
}
const feeRcLabel = formatRcLabelFromCoinsBigInt(
integrity.entryFeeCoinsTotal,
);
const purchaseRcLabel = formatRcLabelFromCoinsBigInt(
integrity.purchaseCoinsTotal,
);
const entryHoldRcLabel = formatRcLabelFromCoinsBigInt(
integrity.entryHoldCoinsTotal,
);
const holdRewardMismatch =
integrity.entryHoldCoinsTotal !== integrity.rewardCoinsTotal;
return (
<section className="space-y-6">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<Link
href="/ledger-book"
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
>
Ledger book
</Link>
{" — "}
double-entry view by date range (default last month UTC).
</p>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Rows loaded
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
{transactions.length.toLocaleString("en-US")}
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Mint total ( from)
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-emerald-800 dark:text-emerald-200">
{formatBigAmount(integrity.mintTotal)}
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Fee collected (entry_fee)
</p>
<p
className="mt-1 text-2xl font-semibold tabular-nums text-violet-800 dark:text-violet-200"
title={`${formatBigAmount(integrity.entryFeeCoinsTotal)} coins`}
>
{feeRcLabel}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrity.entryFeeCoinsTotal)} coins
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Total purchase
</p>
<p
className="mt-1 text-2xl font-semibold tabular-nums text-sky-800 dark:text-sky-200"
title={`${formatBigAmount(integrity.purchaseCoinsTotal)} coins`}
>
{purchaseRcLabel}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrity.purchaseCoinsTotal)} coins
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Total entry_hold / reward
</p>
<p
className="mt-1 text-2xl font-semibold tabular-nums text-amber-800 dark:text-amber-200"
title={`${formatBigAmount(integrity.entryHoldCoinsTotal)} coins`}
>
{entryHoldRcLabel}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrity.entryHoldCoinsTotal)} coins
</p>
</div>
<div
className={[
"rounded-xl border p-4 shadow-sm",
integrity.balanced
? "border-emerald-200 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/40"
: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
].join(" ")}
>
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<div className="mt-1 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
{formatBigAmount(integrity.sumUserNets)}
</p>
<p className="font-mono text-zinc-600 dark:text-zinc-400">
={" "}
{formatBigAmount(
integrity.mintTotal - integrity.sinkTotal,
)}
</p>
</div>
<p className="mt-1 text-xs font-medium text-zinc-700 dark:text-zinc-300">
{integrity.balanced ? "Balanced ✓" : "Mismatch — inspect data"}
</p>
</div>
</div>
{holdRewardMismatch ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
Data mismatch: <span className="font-mono">entry_hold</span> total (
{formatBigAmount(integrity.entryHoldCoinsTotal)} coins) does not equal{" "}
<span className="font-mono">reward</span> total (
{formatBigAmount(integrity.rewardCoinsTotal)} coins).
</div>
) : null}
{!integrity.volumeBalanced ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
Debit / credit volume: debits + mint (
{formatBigAmount(integrity.totalDebitVolume + integrity.mintTotal)})
credits + null-{`to`} outflows (
{formatBigAmount(
integrity.totalCreditVolume + integrity.sinkTotal,
)}
). This usually means inconsistent rows (e.g. both {`from`} and{" "}
{`to`} null with non-zero amount).
</div>
) : null}
<div>
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Ledger
</h2>
<p className="mb-3 text-xs text-zinc-500 dark:text-zinc-400">
All {ledgerRowsChronological.length.toLocaleString("en-US")} rows ·
oldest first
</p>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/95 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">From</th>
<th className="px-4 py-3 font-medium">To</th>
<th className="px-4 py-3 font-medium text-right">Amount</th>
<th className="px-4 py-3 font-medium text-right">RC</th>
<th className="px-4 py-3 font-medium">Remarks</th>
<th className="px-4 py-3 font-medium">Match</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{ledgerRowsChronological.length === 0 ? (
<tr>
<td
colSpan={8}
className="px-4 py-8 text-center text-zinc-500"
>
No transactions loaded.
</td>
</tr>
) : (
ledgerRowsChronological.map((row) => {
const amt = txAmountToBigInt(row.amount);
return (
<tr
key={row.id}
className="text-zinc-800 dark:text-zinc-200"
>
<td className="px-4 py-2 font-mono text-xs">{row.id}</td>
<td className="whitespace-nowrap px-4 py-2 text-xs text-zinc-600 dark:text-zinc-400">
{formatTsUtc(row.created_at)}
</td>
<td className="px-4 py-2">
{userCell(row.from, "from")}
</td>
<td className="px-4 py-2">{userCell(row.to, "to")}</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{formatBigAmount(amt)}
</td>
<td className="whitespace-nowrap px-4 py-2 text-right font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-400">
{formatRcLabelFromCoinsBigInt(amt)}
</td>
<td className="px-4 py-2">
{row.remarks ? (
<span
className={[
"inline-block rounded px-2 py-0.5 text-xs font-medium",
remarkClass(row.remarks),
].join(" ")}
>
{row.remarks}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-2 font-mono text-xs">
{row.match_id != null ? (
<Link
href={`/match-logs/${row.match_id}`}
className="text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:text-sky-900 dark:text-sky-300"
>
{row.match_id}
</Link>
) : (
"—"
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{matchChecks.length > 0 ? (
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
By match_id (2 entry_hold + 2 entry_fee + 1 reward)
</h2>
<div className="flex flex-wrap gap-2">
{matchChecks.map((m) => (
<div
key={m.matchId}
className={[
"rounded-lg border px-3 py-2 text-xs shadow-sm",
m.balanced
? "border-emerald-200 bg-white dark:border-emerald-900/60 dark:bg-zinc-900"
: "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40",
].join(" ")}
>
<span className="font-mono font-semibold">#{m.matchId}</span>
<span className="mx-1 text-zinc-400">·</span>
<Link
href={`/match-logs/${m.matchId}`}
className="text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:text-sky-900 dark:text-sky-300"
>
log
</Link>
<span className="mx-1 text-zinc-400">·</span>
{m.balanced ? (
<span className="text-emerald-700 dark:text-emerald-300">
balanced
</span>
) : (
<span className="text-amber-800 dark:text-amber-200">
{m.rewardDeltaCoins > BigInt(0)
? `overspent +${formatBigAmount(m.rewardDeltaCoins)}`
: m.rewardDeltaCoins < BigInt(0)
? `short reward ${formatBigAmount(-m.rewardDeltaCoins)}`
: "structure mismatch"}
</span>
)}
<span className="mx-1 text-zinc-400">·</span>
<span className="font-mono text-zinc-600 dark:text-zinc-400">
h:{m.entryHoldCount} f:{m.entryFeeCount} r:{m.rewardCount}
</span>
</div>
))}
</div>
</div>
) : null}
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
User net (credits debits)
</h2>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium text-right">Net (coins)</th>
<th className="px-4 py-3 font-medium text-right">Net (RC)</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{userNets.length === 0 ? (
<tr>
<td
colSpan={3}
className="px-4 py-6 text-center text-zinc-500"
>
No non-zero balances in loaded rows.
</td>
</tr>
) : (
userNets.map(([uid, bal]) => (
<tr key={uid} className="text-zinc-800 dark:text-zinc-200">
<td className="px-4 py-2 font-mono text-xs">
{userCell(uid, "from")}
</td>
<td
className={[
"px-4 py-2 text-right font-mono tabular-nums",
bal > BigInt(0)
? "text-emerald-700 dark:text-emerald-300"
: bal < BigInt(0)
? "text-rose-700 dark:text-rose-300"
: "",
].join(" ")}
>
{bal > BigInt(0) ? "+" : ""}
{formatBigAmount(bal)}
</td>
<td className="px-4 py-2 text-right font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-400">
{bal < BigInt(0)
? `${formatRcLabelFromCoinsBigInt(-bal)}`
: formatRcLabelFromCoinsBigInt(bal)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</section>
);
}
@@ -63,6 +63,10 @@ export function EditUserCcRcOverlay({
? "matches"
: tab === "players"
? "players"
: tab === "matchmaker"
? "matchmaker"
: tab === "ledger"
? "ledger"
: "dashboard"
}
/>
+27
View File
@@ -32,3 +32,30 @@ export function rcToCoins(rc: number): number | null {
export function formatRcLabelFromCoins(coins: number): string {
return `${coinsToRc(Math.max(0, Math.floor(coins))).toFixed(1)} RC`;
}
/** RC from a non-negative coin total; exact for any bigint magnitude. */
export function formatRcLabelFromCoinsBigInt(coins: bigint): string {
if (coins < BigInt(0)) coins = BigInt(0);
const whole = coins / BigInt(4);
const tenths = coins % BigInt(4);
return `${whole.toString()}.${tenths.toString()} RC`;
}
/** Single-decimal RC amount only (no suffix); non-negative coins. */
export function formatRcDecimalFromCoinsBigInt(coins: bigint): string {
if (coins < BigInt(0)) coins = BigInt(0);
const whole = coins / BigInt(4);
const tenths = coins % BigInt(4);
return `${whole.toString()}.${tenths.toString()}`;
}
/** Signed RC from net coin balance (e.g. cumulative Dr Cr). */
export function formatSignedRcFromCoinsBigInt(coins: bigint): string {
if (coins === BigInt(0)) return "0.0";
const neg = coins < BigInt(0);
const c = neg ? -coins : coins;
const whole = c / BigInt(4);
const tenths = c % BigInt(4);
const s = `${whole.toString()}.${tenths.toString()}`;
return neg ? `\u2212${s}` : s;
}
+3 -1
View File
@@ -4,7 +4,8 @@ export type AdminDashboardTab =
| "dashboard"
| "players"
| "matches"
| "matchmaker";
| "matchmaker"
| "ledger";
export type DashboardUrlQuery = {
tab: AdminDashboardTab;
@@ -22,6 +23,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
if (q.tab === "matches") p.set("tab", "matches");
else if (q.tab === "players") p.set("tab", "players");
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
else if (q.tab === "ledger") p.set("tab", "ledger");
if (q.highlightId) p.set("highlight", q.highlightId);
if (q.participantRaw) p.set("participant", q.participantRaw);
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
+150
View File
@@ -0,0 +1,150 @@
import type { DbTransaction } from "@/types/database";
function txAmountToBigInt(raw: number | string): bigint {
if (typeof raw === "number") {
if (!Number.isFinite(raw)) return BigInt(0);
return BigInt(Math.trunc(raw));
}
const t = String(raw).trim();
if (t === "" || !/^-?\d+$/.test(t)) return BigInt(0);
try {
return BigInt(t);
} catch {
return BigInt(0);
}
}
function formatLedgerDate(iso: string): string {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
const dd = String(d.getUTCDate()).padStart(2, "0");
const mo = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
][d.getUTCMonth()];
const y = d.getUTCFullYear();
return `${dd} ${mo} ${y}`;
} catch {
return iso.slice(0, 10);
}
}
function userLabel(id: number, names: Map<number, string | null>): string {
const u = names.get(id);
if (u != null && u.trim() !== "") {
return `User ${id} (${u})`;
}
return `User ${id}`;
}
function particularsForTx(
tx: DbTransaction,
names: Map<number, string | null>,
): { dr: string; cr: string } {
const remark = tx.remarks?.trim() ? `${tx.remarks}` : "";
const mid = tx.match_id != null ? ` · Match ${tx.match_id}` : "";
const suffix = `${remark}${mid}`;
if (tx.from == null && tx.to != null) {
return {
dr: `${userLabel(tx.to, names)} (receipt)${suffix}`,
cr: `Supply & mint (contra)${suffix}`,
};
}
if (tx.from != null && tx.to == null) {
return {
dr: `External / settlement (sink)${suffix}`,
cr: `${userLabel(tx.from, names)} (payment out)${suffix}`,
};
}
if (tx.from != null && tx.to != null) {
return {
dr: `${userLabel(tx.to, names)} (receiver — debit)${suffix}`,
cr: `${userLabel(tx.from, names)} (payer — credit)${suffix}`,
};
}
return {
dr: `Orphan line (check data)${suffix}`,
cr: `Orphan contra (check data)${suffix}`,
};
}
export type LedgerBookPair = {
dateDisplay: string;
refLabel: string;
matchId: number | null;
drParticulars: string;
crParticulars: string;
amountCoins: bigint;
balanceAfterDebitCoins: bigint;
balanceAfterCreditCoins: bigint;
};
export type LedgerBookBuildResult = {
pairs: LedgerBookPair[];
totalDebitCoins: bigint;
totalCreditCoins: bigint;
balanced: boolean;
};
/** Double-entry lines: each source row becomes Dr then Cr; running balance returns to 0 after every pair. */
export function buildLedgerBookPairs(
transactions: DbTransaction[],
usernameById: Map<number, string | null>,
): LedgerBookBuildResult {
let cumDr = BigInt(0);
let cumCr = BigInt(0);
const pairs: LedgerBookPair[] = [];
for (const tx of transactions) {
const a = txAmountToBigInt(tx.amount);
if (a === BigInt(0)) continue;
const dateDisplay = formatLedgerDate(tx.created_at);
const refLabel = `TX ${tx.id}`;
const { dr, cr } = particularsForTx(tx, usernameById);
cumDr += a;
const balanceAfterDebitCoins = cumDr - cumCr;
cumCr += a;
const balanceAfterCreditCoins = cumDr - cumCr;
const rawMid = tx.match_id;
const matchId =
rawMid == null
? null
: (() => {
const n = Number(rawMid);
return Number.isFinite(n) ? n : null;
})();
pairs.push({
dateDisplay,
refLabel,
matchId,
drParticulars: dr,
crParticulars: cr,
amountCoins: a,
balanceAfterDebitCoins,
balanceAfterCreditCoins,
});
}
const balanced = cumDr === cumCr;
return {
pairs,
totalDebitCoins: cumDr,
totalCreditCoins: cumCr,
balanced,
};
}
+100
View File
@@ -0,0 +1,100 @@
import type { DbTransaction } from "@/types/database";
function txAmountToBigInt(raw: number | string): bigint {
if (typeof raw === "number") {
if (!Number.isFinite(raw)) return BigInt(0);
return BigInt(Math.trunc(raw));
}
const t = String(raw).trim();
if (t === "" || !/^-?\d+$/.test(t)) return BigInt(0);
try {
return BigInt(t);
} catch {
return BigInt(0);
}
}
function normRemark(remarks: string | null): string {
return (remarks ?? "").trim().toLowerCase();
}
export type MatchEconomicAudit = {
matchId: number;
entryHoldCount: number;
entryFeeCount: number;
rewardCount: number;
entryHoldTotalCoins: bigint;
entryFeeTotalCoins: bigint;
rewardTotalCoins: bigint;
/** reward - entry_hold. Positive means extra reward spent. */
rewardDeltaCoins: bigint;
/** Per business rule this is the match profit. */
profitCoins: bigint;
structurallyBalanced: boolean;
amountBalanced: boolean;
balanced: boolean;
};
export function auditMatchEconomics(
rows: DbTransaction[],
): MatchEconomicAudit[] {
const byMatch = new Map<number, DbTransaction[]>();
for (const row of rows) {
if (row.match_id == null) continue;
const mid = Number(row.match_id);
if (!Number.isFinite(mid)) continue;
const list = byMatch.get(mid) ?? [];
list.push(row);
byMatch.set(mid, list);
}
const audits: MatchEconomicAudit[] = [];
for (const [matchId, list] of [...byMatch.entries()].sort(
(a, b) => a[0] - b[0],
)) {
let entryHoldCount = 0;
let entryFeeCount = 0;
let rewardCount = 0;
let entryHoldTotalCoins = BigInt(0);
let entryFeeTotalCoins = BigInt(0);
let rewardTotalCoins = BigInt(0);
for (const row of list) {
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) continue;
const remark = normRemark(row.remarks);
if (remark === "entry_hold") {
entryHoldCount += 1;
entryHoldTotalCoins += a;
} else if (remark === "entry_fee") {
entryFeeCount += 1;
entryFeeTotalCoins += a;
} else if (remark === "reward") {
rewardCount += 1;
rewardTotalCoins += a;
}
}
const rewardDeltaCoins = rewardTotalCoins - entryHoldTotalCoins;
const structurallyBalanced =
entryHoldCount === 2 && entryFeeCount === 2 && rewardCount === 1;
const amountBalanced = rewardDeltaCoins === BigInt(0);
audits.push({
matchId,
entryHoldCount,
entryFeeCount,
rewardCount,
entryHoldTotalCoins,
entryFeeTotalCoins,
rewardTotalCoins,
rewardDeltaCoins,
profitCoins: entryFeeTotalCoins,
structurallyBalanced,
amountBalanced,
balanced: structurallyBalanced && amountBalanced,
});
}
return audits;
}
+14
View File
@@ -30,3 +30,17 @@ export type DbSetting = {
key: string;
value: string | null;
};
/** Mirrors `public.transactions` (see schemas/transactions.md). */
export type DbTransaction = {
id: number;
created_at: string;
/** Payer; null — e.g. mint / supply (no debit account). */
from: number | null;
/** Payee; null — e.g. burn (credits nowhere). */
to: number | null;
/** BIGINT; may deserialize as string over JSON. */
amount: number | string;
remarks: string | null;
match_id: number | null;
};