ledger
This commit is contained in:
@@ -32,7 +32,9 @@ export async function updateUserCcRc(formData: FormData) {
|
||||
? "players"
|
||||
: tabRaw === "matchmaker"
|
||||
? "matchmaker"
|
||||
: "dashboard";
|
||||
: tabRaw === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard";
|
||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+21
-2
@@ -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,7 +42,9 @@ export default async function Home({
|
||||
? "players"
|
||||
: tabParam === "matchmaker"
|
||||
? "matchmaker"
|
||||
: "dashboard";
|
||||
: tabParam === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard";
|
||||
const highlightId = firstSearchParam(sp.highlight);
|
||||
const participantRaw = firstSearchParam(sp.participant);
|
||||
const editRaw = firstSearchParam(sp.edit);
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,7 +63,11 @@ export function EditUserCcRcOverlay({
|
||||
? "matches"
|
||||
: tab === "players"
|
||||
? "players"
|
||||
: "dashboard"
|
||||
: tab === "matchmaker"
|
||||
? "matchmaker"
|
||||
: tab === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard"
|
||||
}
|
||||
/>
|
||||
<input
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user