ledger
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user