/** * 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; // invoiceId → void amount credited unlockedTools: ToolUnlockRecord[]; }; type LedgerData = { v: 1; handles: Record; /** invoiceId → handle — written at invoice creation so webhook can credit the right account */ invoiceIndex: Record; }; // ─── 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 { 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); }