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 "@shared/lib/types"; import { UserError } from "@/lib/errors"; import { TimerType, TransactionType } from "@shared/lib/constants"; 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: TransactionType.TRANSFER_OUT, description: `Transfer to ${toUserId}`, }); // 2. Credit for receiver await txFn.insert(transactions).values({ userId: BigInt(toUserId), amount: amount, type: TransactionType.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(); // Check cooldown const cooldown = await txFn.query.userTimers.findFirst({ where: and( eq(userTimers.userId, BigInt(userId)), eq(userTimers.type, TimerType.COOLDOWN), eq(userTimers.key, 'daily') ), }); if (cooldown && cooldown.expiresAt > now) { throw new UserError(`Daily already claimed today. Next claim `); } // 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"); } let streak = (user.dailyStreak || 0) + 1; // Check if streak should be reset due to missing a day if (cooldown) { const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime(); // If more than 24h passed since it became ready, they missed a full calendar day if (timeSinceReady > 24 * 60 * 60 * 1000) { streak = 1; } } else if ((user.dailyStreak || 0) > 0) { // If no cooldown record exists but user has a streak, // we'll allow one "free" increment to restore the timer state. // This prevents unfair resets if timers were cleared/lost. streak = (user.dailyStreak || 0) + 1; } else { streak = 1; } const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus; // Weekly bonus check const isWeeklyCurrent = streak > 0 && streak % 7 === 0; const weeklyBonusAmount = isWeeklyCurrent ? config.economy.daily.weeklyBonus : 0n; const totalReward = config.economy.daily.amount + bonus + weeklyBonusAmount; 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 (Next UTC Midnight) const nextReadyAt = new Date(now); nextReadyAt.setUTCDate(nextReadyAt.getUTCDate() + 1); nextReadyAt.setUTCHours(0, 0, 0, 0); await txFn.insert(userTimers) .values({ userId: BigInt(userId), type: TimerType.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: TransactionType.DAILY_REWARD, description: `Daily reward (Streak: ${streak})`, }); return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount }; }, 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); }, };