Files
tis/src/lib/wallet/service.ts
T
Claude Sonnet 5 c4e2132717 Restore from Gitea ZIP snapshot (12.08.2026) after full instance reinstall
Git history was lost when the previous Gitea instance was wiped and
reinstalled due to an unresolved corruption bug — this commit is the
last known-good file content, exported before the reinstall. Prior
commit history is not recoverable through this path.
2026-08-12 19:24:02 +00:00

106 lines
3.3 KiB
TypeScript

import { eq, desc } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { wallets, coinTransactions } from "@/lib/db/schema";
export type CoinReason = "signup_bonus" | "manual_save" | "purchase";
export interface WalletDTO {
userId: string;
balance: number;
}
export interface CoinTransactionDTO {
id: string;
amount: number;
reason: CoinReason;
relatedGameId: string | null;
createdAt: number;
}
export async function getOrCreateWallet(userId: string): Promise<WalletDTO> {
const existing = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
if (existing) return existing;
db.insert(wallets).values({ userId, balance: 0 }).onConflictDoNothing().run();
const created = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
return created!;
}
export async function getBalance(userId: string): Promise<number> {
const wallet = await getOrCreateWallet(userId);
return wallet.balance;
}
export async function listRecentTransactions(userId: string, limit = 20): Promise<CoinTransactionDTO[]> {
const rows = await db.query.coinTransactions.findMany({
where: eq(coinTransactions.userId, userId),
orderBy: desc(coinTransactions.createdAt),
limit,
});
return rows.map((row) => ({
id: row.id,
amount: row.amount,
reason: row.reason,
relatedGameId: row.relatedGameId,
createdAt: row.createdAt.getTime(),
}));
}
/**
* Credits coins and logs the ledger entry atomically. The better-sqlite3
* driver's db.transaction() callback must stay fully synchronous — no
* await inside — or the transaction commits before the awaited work
* finishes; every query here uses the sync .get()/.run() methods.
*/
export async function grantCoins(
userId: string,
amount: number,
reason: CoinReason,
relatedGameId?: string,
): Promise<void> {
await getOrCreateWallet(userId);
db.transaction((tx) => {
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
const balance = wallet?.balance ?? 0;
tx.update(wallets)
.set({ balance: balance + amount, updatedAt: new Date() })
.where(eq(wallets.userId, userId))
.run();
tx.insert(coinTransactions)
.values({ userId, amount, reason, relatedGameId: relatedGameId ?? null })
.run();
});
}
/**
* Debits coins and logs the ledger entry atomically — returns false without
* writing anything if the balance is insufficient. This is the one place
* correctness really matters: the balance check and the debit happen inside
* the same transaction, so two concurrent spends can't both pass the check
* against a stale balance.
*/
export async function spendCoins(
userId: string,
amount: number,
reason: CoinReason,
relatedGameId?: string,
): Promise<boolean> {
await getOrCreateWallet(userId);
return db.transaction((tx) => {
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
const balance = wallet?.balance ?? 0;
if (balance < amount) return false;
tx.update(wallets)
.set({ balance: balance - amount, updatedAt: new Date() })
.where(eq(wallets.userId, userId))
.run();
tx.insert(coinTransactions)
.values({ userId, amount: -amount, reason, relatedGameId: relatedGameId ?? null })
.run();
return true;
});
}