219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|