import { users, transactions, userTimers } from "@/db/schema"; import { eq, sql, and } from "drizzle-orm"; import { config } from "@/lib/config"; import { withTransaction } from "@/lib/db"; import type { Transaction } from "@/lib/types"; import { UserError } from "@/lib/errors"; export const economyService = { transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => { if (amount <= 0n) { throw new UserError("Amount must be positive"); } if (fromUserId === toUserId) { throw new UserError("Cannot transfer to self"); } return await withTransaction(async (txFn) => { // Check sender balance const sender = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(fromUserId)), }); if (!sender) { throw new UserError("Sender not found"); } if ((sender.balance ?? 0n) < amount) { throw new UserError("Insufficient funds"); } // Deduct from sender await txFn.update(users) .set({ balance: sql`${users.balance} - ${amount}`, }) .where(eq(users.id, BigInt(fromUserId))); // Add to receiver await txFn.update(users) .set({ balance: sql`${users.balance} + ${amount}`, }) .where(eq(users.id, BigInt(toUserId))); // Create transaction records // 1. Debit for sender await txFn.insert(transactions).values({ userId: BigInt(fromUserId), amount: -amount, type: 'TRANSFER_OUT', description: `Transfer to ${toUserId}`, }); // 2. Credit for receiver await txFn.insert(transactions).values({ userId: BigInt(toUserId), amount: amount, type: 'TRANSFER_IN', description: `Transfer from ${fromUserId}`, }); return { success: true, amount }; }, tx); }, claimDaily: async (userId: string, tx?: Transaction) => { return await withTransaction(async (txFn) => { const now = new Date(); const startOfDay = new Date(now); startOfDay.setHours(0, 0, 0, 0); // Check cooldown const cooldown = await txFn.query.userTimers.findFirst({ where: and( eq(userTimers.userId, BigInt(userId)), eq(userTimers.type, 'COOLDOWN'), eq(userTimers.key, 'daily') ), }); if (cooldown && cooldown.expiresAt > now) { throw new UserError(`Daily already claimed. Ready at ${cooldown.expiresAt}`); } // Get user for streak logic const user = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(userId)), }); if (!user) { throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now } let streak = (user.dailyStreak || 0) + 1; // If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reduce streak by one for each day passed minimum 1 if (cooldown) { const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime(); if (timeSinceReady > 24 * 60 * 60 * 1000) { streak = Math.max(1, streak - Math.floor(timeSinceReady / (24 * 60 * 60 * 1000))); } } else { streak = 1; } const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus; const totalReward = config.economy.daily.amount + bonus; await txFn.update(users) .set({ balance: sql`${users.balance} + ${totalReward}`, dailyStreak: streak, xp: sql`${users.xp} + 10`, // Small XP reward for daily }) .where(eq(users.id, BigInt(userId))); // Set new cooldown (now + 24h) const nextReadyAt = new Date(now.getTime() + config.economy.daily.cooldownMs); await txFn.insert(userTimers) .values({ userId: BigInt(userId), type: 'COOLDOWN', key: 'daily', expiresAt: nextReadyAt, }) .onConflictDoUpdate({ target: [userTimers.userId, userTimers.type, userTimers.key], set: { expiresAt: nextReadyAt }, }); // Log Transaction await txFn.insert(transactions).values({ userId: BigInt(userId), amount: totalReward, type: 'DAILY_REWARD', description: `Daily reward (Streak: ${streak})`, }); return { claimed: true, amount: totalReward, streak, nextReadyAt }; }, tx); }, modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => { return await withTransaction(async (txFn) => { if (amount < 0n) { // Check sufficient funds if removing const user = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(id)) }); if (!user || (user.balance ?? 0n) < -amount) { throw new UserError("Insufficient funds"); } } const [user] = await txFn.update(users) .set({ balance: sql`${users.balance} + ${amount}`, }) .where(eq(users.id, BigInt(id))) .returning(); await txFn.insert(transactions).values({ userId: BigInt(id), relatedUserId: relatedUserId ? BigInt(relatedUserId) : null, amount: amount, type: type, description: description, }); return user; }, tx); }, };