ledger
This commit is contained in:
@@ -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;
|
||||||
@@ -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
|
||||||
|
@@ -32,7 +32,9 @@ export async function updateUserCcRc(formData: FormData) {
|
|||||||
? "players"
|
? "players"
|
||||||
: tabRaw === "matchmaker"
|
: tabRaw === "matchmaker"
|
||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: "dashboard";
|
: tabRaw === "ledger"
|
||||||
|
? "ledger"
|
||||||
|
: "dashboard";
|
||||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||||
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
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,
|
type MatchmakerLogSource,
|
||||||
} from "@/lib/matchmaker-log-source";
|
} from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -42,7 +42,9 @@ export default async function Home({
|
|||||||
? "players"
|
? "players"
|
||||||
: tabParam === "matchmaker"
|
: tabParam === "matchmaker"
|
||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: "dashboard";
|
: tabParam === "ledger"
|
||||||
|
? "ledger"
|
||||||
|
: "dashboard";
|
||||||
const highlightId = firstSearchParam(sp.highlight);
|
const highlightId = firstSearchParam(sp.highlight);
|
||||||
const participantRaw = firstSearchParam(sp.participant);
|
const participantRaw = firstSearchParam(sp.participant);
|
||||||
const editRaw = firstSearchParam(sp.edit);
|
const editRaw = firstSearchParam(sp.edit);
|
||||||
@@ -56,6 +58,8 @@ export default async function Home({
|
|||||||
let configError: string | null = null;
|
let configError: string | null = null;
|
||||||
let usersError: string | null = null;
|
let usersError: string | null = null;
|
||||||
let matchesError: string | null = null;
|
let matchesError: string | null = null;
|
||||||
|
let transactions: DbTransaction[] = [];
|
||||||
|
let transactionsError: string | null = null;
|
||||||
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
@@ -102,6 +106,19 @@ export default async function Home({
|
|||||||
}
|
}
|
||||||
|
|
||||||
statsBundle = await loadDashboardStatsBundle(supabase);
|
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;
|
let editUser: DbUser | null = null;
|
||||||
@@ -128,6 +145,8 @@ export default async function Home({
|
|||||||
matches={matches}
|
matches={matches}
|
||||||
usersError={usersError}
|
usersError={usersError}
|
||||||
matchesError={matchesError}
|
matchesError={matchesError}
|
||||||
|
transactions={transactions}
|
||||||
|
transactionsError={transactionsError}
|
||||||
statsBundle={statsBundle}
|
statsBundle={statsBundle}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
highlightId={highlightId}
|
highlightId={highlightId}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { AdminLedger } from "@/components/admin-ledger";
|
||||||
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||||||
import type { DashboardStatsBundle } from "@/lib/dashboard-stats";
|
import type { DashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
type AdminDashboardTab,
|
type AdminDashboardTab,
|
||||||
} from "@/lib/dashboard-search-url";
|
} from "@/lib/dashboard-search-url";
|
||||||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
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. */
|
/** Bigint columns often arrive as strings from PostgREST / JSON. */
|
||||||
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
|
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
|
||||||
@@ -58,6 +59,8 @@ type Props = {
|
|||||||
matches: DbMatch[];
|
matches: DbMatch[];
|
||||||
usersError: string | null;
|
usersError: string | null;
|
||||||
matchesError: string | null;
|
matchesError: string | null;
|
||||||
|
transactions: DbTransaction[];
|
||||||
|
transactionsError: string | null;
|
||||||
statsBundle: DashboardStatsBundle | null;
|
statsBundle: DashboardStatsBundle | null;
|
||||||
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
|
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
|
||||||
tab: AdminDashboardTab;
|
tab: AdminDashboardTab;
|
||||||
@@ -96,6 +99,8 @@ export function AdminDashboard({
|
|||||||
matches,
|
matches,
|
||||||
usersError,
|
usersError,
|
||||||
matchesError,
|
matchesError,
|
||||||
|
transactions,
|
||||||
|
transactionsError,
|
||||||
statsBundle,
|
statsBundle,
|
||||||
tab,
|
tab,
|
||||||
highlightId,
|
highlightId,
|
||||||
@@ -228,6 +233,17 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Matchmaker
|
Matchmaker
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={buildDashboardHref({
|
||||||
|
tab: "ledger",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
})}
|
||||||
|
className={tabClass(tab === "ledger")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Ledger
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className={tabClass(false)}
|
className={tabClass(false)}
|
||||||
@@ -485,6 +501,14 @@ export function AdminDashboard({
|
|||||||
errorMessage={matchmakerError}
|
errorMessage={matchmakerError}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
) : tab === "ledger" ? (
|
||||||
|
<AdminLedger
|
||||||
|
transactions={transactions}
|
||||||
|
users={users}
|
||||||
|
error={transactionsError}
|
||||||
|
highlightId={highlightId}
|
||||||
|
participantRaw={participantRaw}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
{participantId != null ? (
|
{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"
|
? "matches"
|
||||||
: tab === "players"
|
: tab === "players"
|
||||||
? "players"
|
? "players"
|
||||||
: "dashboard"
|
: tab === "matchmaker"
|
||||||
|
? "matchmaker"
|
||||||
|
: tab === "ledger"
|
||||||
|
? "ledger"
|
||||||
|
: "dashboard"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -32,3 +32,30 @@ export function rcToCoins(rc: number): number | null {
|
|||||||
export function formatRcLabelFromCoins(coins: number): string {
|
export function formatRcLabelFromCoins(coins: number): string {
|
||||||
return `${coinsToRc(Math.max(0, Math.floor(coins))).toFixed(1)} RC`;
|
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"
|
| "dashboard"
|
||||||
| "players"
|
| "players"
|
||||||
| "matches"
|
| "matches"
|
||||||
| "matchmaker";
|
| "matchmaker"
|
||||||
|
| "ledger";
|
||||||
|
|
||||||
export type DashboardUrlQuery = {
|
export type DashboardUrlQuery = {
|
||||||
tab: AdminDashboardTab;
|
tab: AdminDashboardTab;
|
||||||
@@ -22,6 +23,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
|
|||||||
if (q.tab === "matches") p.set("tab", "matches");
|
if (q.tab === "matches") p.set("tab", "matches");
|
||||||
else if (q.tab === "players") p.set("tab", "players");
|
else if (q.tab === "players") p.set("tab", "players");
|
||||||
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
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.highlightId) p.set("highlight", q.highlightId);
|
||||||
if (q.participantRaw) p.set("participant", q.participantRaw);
|
if (q.participantRaw) p.set("participant", q.participantRaw);
|
||||||
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
|
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;
|
key: string;
|
||||||
value: string | null;
|
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