SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
2.6 KB · 65 lines typescript
Raw Blame History
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