TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import { creditTransactions, eq, sql, wallets, type Tx } from "@spinza/database";2import type { TransactionType } from "@spinza/shared";3import { errors } from "../lib/errors";45export interface LockedWallet {6 userId: string;7 balance: number;8 lifetimeWagered: number;9 lifetimeWon: number;10 lifetimeGranted: number;11}1213/** SELECT ... FOR UPDATE on the wallet row. Every balance change must start here. */14export async function lockWallet(tx: Tx, userId: string): Promise<LockedWallet> {15 const rows = await tx.execute(sql`select user_id, balance, lifetime_wagered, lifetime_won, lifetime_granted from wallets where user_id = ${userId} for update`);16 const r = (rows.rows as Record<string, unknown>[])[0];17 if (!r) throw errors.notFound("Wallet not found");18 return {19 userId,20 balance: Number(r.balance),21 lifetimeWagered: Number(r.lifetime_wagered),22 lifetimeWon: Number(r.lifetime_won),23 lifetimeGranted: Number(r.lifetime_granted),24 };25}2627/**28 * Apply a signed amount to a locked wallet and write the immutable ledger row.29 * Never call without holding the wallet lock (see lockWallet).30 */31export async function applyCredit(32 tx: Tx,33 wallet: LockedWallet,34 type: TransactionType,35 amount: number,36 reference: string | null,37 meta?: Record<string, unknown>,38): Promise<number> {39 if (!Number.isInteger(amount)) throw new Error("credit amounts must be integers");40 const next = wallet.balance + amount;41 if (next < 0) throw errors.insufficient(wallet.balance, -amount);42 wallet.balance = next;43 if (type === "BET") wallet.lifetimeWagered += -amount;44 else if (type === "WIN") wallet.lifetimeWon += amount;45 else if (amount > 0) wallet.lifetimeGranted += amount;46 await tx.insert(creditTransactions).values({ userId: wallet.userId, type, amount, balanceAfter: next, reference, meta: meta ?? null });47 return next;48}4950/** Persist the wallet aggregate after one or more applyCredit calls. */51export async function saveWallet(tx: Tx, wallet: LockedWallet): Promise<void> {52 await tx53 .update(wallets)54 .set({ balance: wallet.balance, lifetimeWagered: wallet.lifetimeWagered, lifetimeWon: wallet.lifetimeWon, lifetimeGranted: wallet.lifetimeGranted, updatedAt: new Date() })55 .where(eq(wallets.userId, wallet.userId));56}5758/** Convenience: single grant in its own transaction. */59export async function grant(tx: Tx, userId: string, type: TransactionType, amount: number, reference: string | null, meta?: Record<string, unknown>): Promise<number> {60 const w = await lockWallet(tx, userId);61 const b = await applyCredit(tx, w, type, amount, reference, meta);62 await saveWallet(tx, w);63 return b;64}65