diff --git a/schemas/transactions.md b/schemas/transactions.md new file mode 100644 index 0000000..781c330 --- /dev/null +++ b/schemas/transactions.md @@ -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; \ No newline at end of file diff --git a/schemas/transactions_table_data.csv b/schemas/transactions_table_data.csv new file mode 100644 index 0000000..bade57b --- /dev/null +++ b/schemas/transactions_table_data.csv @@ -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 \ No newline at end of file diff --git a/src/app/actions/update-user-cc-rc.ts b/src/app/actions/update-user-cc-rc.ts index 5822e95..107aff0 100644 --- a/src/app/actions/update-user-cc-rc.ts +++ b/src/app/actions/update-user-cc-rc.ts @@ -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; diff --git a/src/app/ledger-book/page.tsx b/src/app/ledger-book/page.tsx new file mode 100644 index 0000000..f16c635 --- /dev/null +++ b/src/app/ledger-book/page.tsx @@ -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 { + 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(); + for (const u of userNameRows) { + usernameById.set(u.id, u.username ?? null); + } + + const book = + transactions.length === 0 + ? { + pairs: [] as ReturnType["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 ( +
+ +
+
+

+ Ledger book +

+

+ Double-entry presentation in{" "} + RC{" "} + (from on-chain coin units). Each transaction posts a debit then a + matching credit; running balance returns to{" "} + + 0.0 + {" "} + after every pair. Period totals must match for a balanced book. +

+
+ +
+
+
+ + +
+
+ + +
+ + + Reset to last month (UTC) + + + ← Dashboard + +
+

+ Inclusive UTC range: {rangeStartIso.slice(0, 10)} →{" "} + {rangeEndIso.slice(0, 10)} · {transactions.length.toLocaleString("en-US")}{" "} + transactions loaded (cap 10,000). +

+
+ + {configError ? ( +
+ {configError} +
+ ) : loadError ? ( +
+

{loadError}

+
+ ) : ( + <> +
+
+ Period balance: + {book.balanced ? ( + + Total debits = total credits ( + {formatRcLabelFromCoinsBigInt(book.totalDebitCoins)} in + coin-derived RC) — balanced ✓ + + ) : ( + + Mismatch — debits{" "} + {formatRcLabelFromCoinsBigInt(book.totalDebitCoins)} vs credits{" "} + {formatRcLabelFromCoinsBigInt(book.totalCreditCoins)} + + )} +
+
+ Match economics: + {rewardDeltaTotalCoins === BigInt(0) && + unbalancedMatches.length === 0 ? ( + + reward equals entry_hold for all matches — balanced ✓ + + ) : ( + + reward-entry_hold delta{" "} + + {rewardDeltaTotalCoins > BigInt(0) + ? `+${rewardDeltaTotalCoins.toString()}` + : rewardDeltaTotalCoins.toString()} + {" "} + coins across {unbalancedMatches.length} unbalanced match + {unbalancedMatches.length === 1 ? "" : "es"} + + )} +
+
+ +
+ + + + + + + + + + + + + {book.pairs.length === 0 ? ( + + + + ) : ( + book.pairs.map((p, idx) => { + const rcAmt = formatRcDecimalFromCoinsBigInt(p.amountCoins); + const refCell = + p.matchId != null ? ( + + {p.refLabel} + · + + M{p.matchId} + + + ) : ( + {p.refLabel} + ); + return ( + + + + + + + + + + + + + + + + + ); + }) + )} + + {book.pairs.length > 0 ? ( + + + + + + + + + ) : null} +
+ Date + + Particulars + + Folio / ref + + Debit (RC) + + Credit (RC) + + Balance (RC) +
+ No transactions in this range. +
+ {p.dateDisplay} + + {p.drParticulars} + + {refCell} + + {rcAmt} + + — + + {formatSignedRcFromCoinsBigInt( + p.balanceAfterDebitCoins, + )} +
+ {p.crParticulars} + + — + + {rcAmt} + + {formatSignedRcFromCoinsBigInt( + p.balanceAfterCreditCoins, + )} +
+ Period totals + + {formatRcDecimalFromCoinsBigInt(book.totalDebitCoins)} + + {formatRcDecimalFromCoinsBigInt(book.totalCreditCoins)} + + {formatSignedRcFromCoinsBigInt( + book.totalDebitCoins - book.totalCreditCoins, + )} +
+
+ + )} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1285ecb..be518ba 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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> | 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} diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index 5fd4d85..a1bd353 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -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 + + Ledger + + ) : tab === "ledger" ? ( + ) : (
{participantId != null ? ( diff --git a/src/components/admin-ledger.tsx b/src/components/admin-ledger.tsx new file mode 100644 index 0000000..7c1378b --- /dev/null +++ b/src/components/admin-ledger.tsx @@ -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(); + 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(); + 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(); + 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 ( + + {role === "from" ? "∅ mint" : "∅"} + + ); + } + const un = usernameById.get(id); + return ( + + + {id} + + {un != null && un !== "" ? ( + ({un}) + ) : null} + + ); + } + + const feeRcLabel = formatRcLabelFromCoinsBigInt( + integrity.entryFeeCoinsTotal, + ); + const purchaseRcLabel = formatRcLabelFromCoinsBigInt( + integrity.purchaseCoinsTotal, + ); + const entryHoldRcLabel = formatRcLabelFromCoinsBigInt( + integrity.entryHoldCoinsTotal, + ); + const holdRewardMismatch = + integrity.entryHoldCoinsTotal !== integrity.rewardCoinsTotal; + + return ( +
+

+ + Ledger book + + {" — "} + double-entry view by date range (default last month UTC). +

+ + {error ? ( +

{error}

+ ) : null} + +
+
+

+ Rows loaded +

+

+ {transactions.length.toLocaleString("en-US")} +

+
+
+

+ Mint total (∅ from) +

+

+ {formatBigAmount(integrity.mintTotal)} +

+
+
+

+ Fee collected (entry_fee) +

+

+ {feeRcLabel} +

+

+ {formatBigAmount(integrity.entryFeeCoinsTotal)} coins +

+
+
+

+ Total purchase +

+

+ {purchaseRcLabel} +

+

+ {formatBigAmount(integrity.purchaseCoinsTotal)} coins +

+
+
+

+ Total entry_hold / reward +

+

+ {entryHoldRcLabel} +

+

+ {formatBigAmount(integrity.entryHoldCoinsTotal)} coins +

+
+
+

+ Σ user nets vs net issued +

+
+

+ {formatBigAmount(integrity.sumUserNets)} +

+

+ ={" "} + {formatBigAmount( + integrity.mintTotal - integrity.sinkTotal, + )} +

+
+

+ {integrity.balanced ? "Balanced ✓" : "Mismatch — inspect data"} +

+
+
+ {holdRewardMismatch ? ( +
+ Data mismatch: entry_hold total ( + {formatBigAmount(integrity.entryHoldCoinsTotal)} coins) does not equal{" "} + reward total ( + {formatBigAmount(integrity.rewardCoinsTotal)} coins). +
+ ) : null} + + {!integrity.volumeBalanced ? ( +
+ 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). +
+ ) : null} + +
+

+ Ledger +

+

+ All {ledgerRowsChronological.length.toLocaleString("en-US")} rows · + oldest first +

+
+ + + + + + + + + + + + + + + {ledgerRowsChronological.length === 0 ? ( + + + + ) : ( + ledgerRowsChronological.map((row) => { + const amt = txAmountToBigInt(row.amount); + return ( + + + + + + + + + + + ); + }) + )} + +
IDCreatedFromToAmountRCRemarksMatch
+ No transactions loaded. +
{row.id} + {formatTsUtc(row.created_at)} + + {userCell(row.from, "from")} + {userCell(row.to, "to")} + {formatBigAmount(amt)} + + {formatRcLabelFromCoinsBigInt(amt)} + + {row.remarks ? ( + + {row.remarks} + + ) : ( + "—" + )} + + {row.match_id != null ? ( + + {row.match_id} + + ) : ( + "—" + )} +
+
+
+ + {matchChecks.length > 0 ? ( +
+

+ By match_id (2 entry_hold + 2 entry_fee + 1 reward) +

+
+ {matchChecks.map((m) => ( +
+ #{m.matchId} + · + + log + + · + {m.balanced ? ( + + balanced + + ) : ( + + {m.rewardDeltaCoins > BigInt(0) + ? `overspent +${formatBigAmount(m.rewardDeltaCoins)}` + : m.rewardDeltaCoins < BigInt(0) + ? `short reward ${formatBigAmount(-m.rewardDeltaCoins)}` + : "structure mismatch"} + + )} + · + + h:{m.entryHoldCount} f:{m.entryFeeCount} r:{m.rewardCount} + +
+ ))} +
+
+ ) : null} + +
+

+ User net (credits − debits) +

+
+ + + + + + + + + + {userNets.length === 0 ? ( + + + + ) : ( + userNets.map(([uid, bal]) => ( + + + + + + )) + )} + +
UserNet (coins)Net (RC)
+ No non-zero balances in loaded rows. +
+ {userCell(uid, "from")} + 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)} + + {bal < BigInt(0) + ? `−${formatRcLabelFromCoinsBigInt(-bal)}` + : formatRcLabelFromCoinsBigInt(bal)} +
+
+
+
+ ); +} diff --git a/src/components/edit-user-cc-rc-overlay.tsx b/src/components/edit-user-cc-rc-overlay.tsx index fa57ee5..f974907 100644 --- a/src/components/edit-user-cc-rc-overlay.tsx +++ b/src/components/edit-user-cc-rc-overlay.tsx @@ -63,7 +63,11 @@ export function EditUserCcRcOverlay({ ? "matches" : tab === "players" ? "players" - : "dashboard" + : tab === "matchmaker" + ? "matchmaker" + : tab === "ledger" + ? "ledger" + : "dashboard" } /> ): string { + const u = names.get(id); + if (u != null && u.trim() !== "") { + return `User ${id} (${u})`; + } + return `User ${id}`; +} + +function particularsForTx( + tx: DbTransaction, + names: Map, +): { 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, +): 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, + }; +} diff --git a/src/lib/ledger-match-audit.ts b/src/lib/ledger-match-audit.ts new file mode 100644 index 0000000..bacc3d3 --- /dev/null +++ b/src/lib/ledger-match-audit.ts @@ -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(); + 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; +} diff --git a/src/types/database.ts b/src/types/database.ts index 22b8a66..f74c606 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -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; +};