Replace remote with local repo
This commit is contained in:
218
lib/serverLedger.ts
Normal file
218
lib/serverLedger.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Server-side VOID credit ledger.
|
||||
* Stored in var/server-ledger.json (gitignored, created on first write).
|
||||
* Uses atomic rename for writes — safe on a single Next.js server.
|
||||
*
|
||||
* VOID credits are the authoritative balance for tool unlocks.
|
||||
* Rate: VOID_CREDIT_SATS_PER env var (default 1000 sats = 1 VOID).
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToolUnlockRecord = {
|
||||
toolId: string;
|
||||
unlockedAt: number;
|
||||
voidSpent: number;
|
||||
};
|
||||
|
||||
export type HandleRecord = {
|
||||
voidCredits: number;
|
||||
claimedInvoices: Record<string, number>; // invoiceId → void amount credited
|
||||
unlockedTools: ToolUnlockRecord[];
|
||||
};
|
||||
|
||||
type LedgerData = {
|
||||
v: 1;
|
||||
handles: Record<string, HandleRecord>;
|
||||
/** invoiceId → handle — written at invoice creation so webhook can credit the right account */
|
||||
invoiceIndex: Record<string, string>;
|
||||
};
|
||||
|
||||
// ─── File paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
const VAR_DIR = path.join(process.cwd(), "var");
|
||||
const LEDGER_PATH = path.join(VAR_DIR, "server-ledger.json");
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ensureVarDir() {
|
||||
if (!fs.existsSync(VAR_DIR)) fs.mkdirSync(VAR_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function emptyLedger(): LedgerData {
|
||||
return { v: 1, handles: {}, invoiceIndex: {} };
|
||||
}
|
||||
|
||||
function emptyHandle(): HandleRecord {
|
||||
return { voidCredits: 0, claimedInvoices: {}, unlockedTools: [] };
|
||||
}
|
||||
|
||||
// ─── I/O ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function readLedger(): LedgerData {
|
||||
try {
|
||||
if (!fs.existsSync(LEDGER_PATH)) return emptyLedger();
|
||||
const raw = fs.readFileSync(LEDGER_PATH, "utf8");
|
||||
const parsed = JSON.parse(raw) as LedgerData;
|
||||
if (parsed?.v !== 1) return emptyLedger();
|
||||
parsed.handles ??= {};
|
||||
parsed.invoiceIndex ??= {};
|
||||
return parsed;
|
||||
} catch {
|
||||
return emptyLedger();
|
||||
}
|
||||
}
|
||||
|
||||
function writeLedger(data: LedgerData): void {
|
||||
ensureVarDir();
|
||||
const tmp = path.join(os.tmpdir(), `cyberlux-ledger-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
||||
fs.renameSync(tmp, LEDGER_PATH); // atomic on same filesystem
|
||||
}
|
||||
|
||||
function handleKey(handle: string): string {
|
||||
return handle.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function getVoidBalance(handle: string): number {
|
||||
const ledger = readLedger();
|
||||
return ledger.handles[handleKey(handle)]?.voidCredits ?? 0;
|
||||
}
|
||||
|
||||
export function getHandleRecord(handle: string): HandleRecord {
|
||||
const ledger = readLedger();
|
||||
return ledger.handles[handleKey(handle)] ?? emptyHandle();
|
||||
}
|
||||
|
||||
/** Register invoice → handle mapping when invoice is created. */
|
||||
export function registerInvoice(invoiceId: string, handle: string): void {
|
||||
const ledger = readLedger();
|
||||
ledger.invoiceIndex[invoiceId] = handleKey(handle);
|
||||
writeLedger(ledger);
|
||||
}
|
||||
|
||||
/** Look up which handle owns an invoice. */
|
||||
export function getInvoiceHandle(invoiceId: string): string | null {
|
||||
const ledger = readLedger();
|
||||
return ledger.invoiceIndex[invoiceId] ?? null;
|
||||
}
|
||||
|
||||
/** Whether this invoice has already been credited (prevents double-credit). */
|
||||
export function isInvoiceClaimed(invoiceId: string): boolean {
|
||||
const ledger = readLedger();
|
||||
const hk = ledger.invoiceIndex[invoiceId];
|
||||
if (!hk) return false;
|
||||
return invoiceId in (ledger.handles[hk]?.claimedInvoices ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit VOID credits to a handle for a settled invoice.
|
||||
* Returns false if invoice already credited.
|
||||
*/
|
||||
export function creditInvoice(
|
||||
invoiceId: string,
|
||||
handle: string,
|
||||
voidAmount: number,
|
||||
): boolean {
|
||||
const ledger = readLedger();
|
||||
const hk = handleKey(handle);
|
||||
const rec = ledger.handles[hk] ?? emptyHandle();
|
||||
|
||||
if (invoiceId in rec.claimedInvoices) return false;
|
||||
|
||||
rec.voidCredits = Math.max(0, (rec.voidCredits ?? 0) + Math.max(0, Math.floor(voidAmount)));
|
||||
rec.claimedInvoices[invoiceId] = Math.floor(voidAmount);
|
||||
ledger.handles[hk] = rec;
|
||||
ledger.invoiceIndex[invoiceId] = hk;
|
||||
writeLedger(ledger);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct VOID credits and mark a tool as unlocked.
|
||||
* Returns { ok: false } if insufficient balance.
|
||||
*/
|
||||
export function unlockTool(
|
||||
handle: string,
|
||||
toolId: string,
|
||||
cost: number,
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
const ledger = readLedger();
|
||||
const hk = handleKey(handle);
|
||||
const rec = ledger.handles[hk] ?? emptyHandle();
|
||||
|
||||
if (rec.unlockedTools.some((t) => t.toolId === toolId)) {
|
||||
return { ok: true }; // already unlocked, idempotent
|
||||
}
|
||||
|
||||
if (rec.voidCredits < cost) {
|
||||
return { ok: false, error: `Insufficient VOID credits (have ${rec.voidCredits}, need ${cost})` };
|
||||
}
|
||||
|
||||
rec.voidCredits -= cost;
|
||||
rec.unlockedTools.push({ toolId, unlockedAt: Date.now(), voidSpent: cost });
|
||||
ledger.handles[hk] = rec;
|
||||
writeLedger(ledger);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Check if a handle has already unlocked a specific tool. */
|
||||
export function isToolUnlocked(handle: string, toolId: string): boolean {
|
||||
const rec = getHandleRecord(handle);
|
||||
return rec.unlockedTools.some((t) => t.toolId === toolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple deduction of VOID credits for arbitrary purchases (e.g. raffle tickets).
|
||||
* Returns { ok: false } if balance is insufficient.
|
||||
*/
|
||||
export function deductVoid(
|
||||
handle: string,
|
||||
amount: number,
|
||||
reason?: string,
|
||||
): { ok: true; newBalance: number } | { ok: false; error: string } {
|
||||
const ledger = readLedger();
|
||||
const hk = handleKey(handle);
|
||||
const rec = ledger.handles[hk] ?? emptyHandle();
|
||||
if (rec.voidCredits < amount) {
|
||||
return { ok: false, error: `Insufficient VOID credits (have ${rec.voidCredits}, need ${amount}${reason ? ` for ${reason}` : ""})` };
|
||||
}
|
||||
rec.voidCredits -= amount;
|
||||
ledger.handles[hk] = rec;
|
||||
writeLedger(ledger);
|
||||
return { ok: true, newBalance: rec.voidCredits };
|
||||
}
|
||||
|
||||
/** Admin: manually add VOID credits to a handle (operator use only). */
|
||||
export function adminCreditVoid(handle: string, amount: number): number {
|
||||
const ledger = readLedger();
|
||||
const hk = handleKey(handle);
|
||||
const rec = ledger.handles[hk] ?? emptyHandle();
|
||||
rec.voidCredits = Math.max(0, rec.voidCredits + Math.max(0, Math.floor(amount)));
|
||||
ledger.handles[hk] = rec;
|
||||
writeLedger(ledger);
|
||||
return rec.voidCredits;
|
||||
}
|
||||
|
||||
/** Return all handle records for admin views. */
|
||||
export function getAllHandles(): Record<string, HandleRecord> {
|
||||
return readLedger().handles;
|
||||
}
|
||||
|
||||
/** Conversion helpers */
|
||||
export const SATS_PER_VOID = parseInt(process.env.VOID_CREDIT_SATS_PER ?? "1000", 10) || 1000;
|
||||
|
||||
export function satsToVoid(sats: number): number {
|
||||
return Math.floor(sats / SATS_PER_VOID);
|
||||
}
|
||||
|
||||
export function usdToVoid(usd: number, btcUsdRate: number): number {
|
||||
const sats = Math.floor((usd / btcUsdRate) * 1e8);
|
||||
return satsToVoid(sats);
|
||||
}
|
||||
Reference in New Issue
Block a user