import { prisma } from "@/lib/prisma"; import type { BankingData, Account, Transaction, Folder, Category, } from "@/lib/types"; import { Prisma } from "@prisma/client"; export interface TransactionsPaginatedParams { limit?: number; offset?: number; startDate?: string; endDate?: string; accountIds?: string[]; categoryIds?: string[]; includeUncategorized?: boolean; search?: string; isReconciled?: boolean | "all"; sortField?: "date" | "amount" | "description"; sortOrder?: "asc" | "desc"; } export interface TransactionsPaginatedResult { transactions: Transaction[]; total: number; hasMore: boolean; } export const bankingService = { async getAllData(): Promise { const [accounts, transactions, folders, categories] = await Promise.all([ prisma.account.findMany({ include: { folder: true, }, }), prisma.transaction.findMany({ // Removed includes - not needed for transformation, only use direct fields select: { id: true, accountId: true, date: true, amount: true, description: true, type: true, categoryId: true, isReconciled: true, fitId: true, memo: true, checkNum: true, }, }), prisma.folder.findMany(), prisma.category.findMany(), ]); // Transform Prisma models to match our types return { accounts: accounts.map( (a): Account => ({ id: a.id, name: a.name, bankId: a.bankId, accountNumber: a.accountNumber, type: a.type as Account["type"], folderId: a.folderId, balance: a.balance, initialBalance: a.initialBalance, currency: a.currency, lastImport: a.lastImport, externalUrl: a.externalUrl, }), ), transactions: transactions.map( (t): Transaction => ({ id: t.id, accountId: t.accountId, date: t.date, amount: t.amount, description: t.description, type: t.type as Transaction["type"], categoryId: t.categoryId, isReconciled: t.isReconciled, fitId: t.fitId, memo: t.memo ?? undefined, checkNum: t.checkNum ?? undefined, }), ), folders: folders.map( (f): Folder => ({ id: f.id, name: f.name, parentId: f.parentId, color: f.color, icon: f.icon, }), ), categories: categories.map( (c): Category => ({ id: c.id, name: c.name, color: c.color, icon: c.icon, keywords: JSON.parse(c.keywords) as string[], parentId: c.parentId, }), ), }; }, async getTransactionsPaginated( params: TransactionsPaginatedParams = {}, ): Promise { const { limit = 50, offset = 0, startDate, endDate, accountIds, categoryIds, includeUncategorized = false, search, isReconciled = "all", sortField = "date", sortOrder = "desc", } = params; // Build where clause const where: Prisma.TransactionWhereInput = {}; // Date filter if (startDate || endDate) { where.date = {}; if (startDate) { where.date.gte = startDate; } if (endDate) { // Add time to end of day const endOfDay = new Date(endDate); endOfDay.setHours(23, 59, 59, 999); where.date.lte = endOfDay.toISOString().split("T")[0]; } } // Account filter if (accountIds && accountIds.length > 0) { where.accountId = { in: accountIds }; } // Category filter const categoryFilter: Prisma.TransactionWhereInput[] = []; if (categoryIds && categoryIds.length > 0) { if (includeUncategorized) { categoryFilter.push({ OR: [{ categoryId: { in: categoryIds } }, { categoryId: null }], }); } else { categoryFilter.push({ categoryId: { in: categoryIds } }); } } else if (includeUncategorized) { categoryFilter.push({ categoryId: null }); } // Search filter (description or memo) // SQLite is case-insensitive by default for ASCII strings if (search && search.trim()) { const searchLower = search.toLowerCase(); categoryFilter.push({ OR: [ { description: { contains: searchLower } }, { memo: { contains: searchLower } }, ], }); } // Combine all filters with AND if we have multiple conditions if (categoryFilter.length > 0) { if (categoryFilter.length === 1) { Object.assign(where, categoryFilter[0]); } else { where.AND = categoryFilter; } } // Reconciled filter if (isReconciled !== "all") { where.isReconciled = isReconciled === true; } // Build orderBy const orderBy: Prisma.TransactionOrderByWithRelationInput[] = []; switch (sortField) { case "date": orderBy.push({ date: sortOrder }); break; case "amount": orderBy.push({ amount: sortOrder }); break; case "description": orderBy.push({ description: sortOrder }); break; } // Add secondary sort by date for consistency if (sortField !== "date") { orderBy.push({ date: "desc" }); } // Get total count const total = await prisma.transaction.count({ where }); // Get paginated transactions const transactions = await prisma.transaction.findMany({ where, orderBy, take: limit, skip: offset, select: { id: true, accountId: true, date: true, amount: true, description: true, type: true, categoryId: true, isReconciled: true, fitId: true, memo: true, checkNum: true, }, }); // Transform to Transaction type const transformedTransactions: Transaction[] = transactions.map( (t): Transaction => ({ id: t.id, accountId: t.accountId, date: t.date, amount: t.amount, description: t.description, type: t.type as Transaction["type"], categoryId: t.categoryId, isReconciled: t.isReconciled, fitId: t.fitId, memo: t.memo ?? undefined, checkNum: t.checkNum ?? undefined, }), ); return { transactions: transformedTransactions, total, hasMore: offset + limit < total, }; }, async getMetadata(): Promise<{ accounts: Account[]; folders: Folder[]; categories: Category[]; }> { const [accounts, folders, categories] = await Promise.all([ prisma.account.findMany({ include: { folder: true, }, }), prisma.folder.findMany(), prisma.category.findMany(), ]); return { accounts: accounts.map( (a): Account => ({ id: a.id, name: a.name, bankId: a.bankId, accountNumber: a.accountNumber, type: a.type as Account["type"], folderId: a.folderId, balance: a.balance, initialBalance: a.initialBalance, currency: a.currency, lastImport: a.lastImport, externalUrl: a.externalUrl, }), ), folders: folders.map( (f): Folder => ({ id: f.id, name: f.name, parentId: f.parentId, color: f.color, icon: f.icon, }), ), categories: categories.map( (c): Category => ({ id: c.id, name: c.name, color: c.color, icon: c.icon, keywords: JSON.parse(c.keywords) as string[], parentId: c.parentId, }), ), }; }, async getCategoryStats(): Promise< Record > { // Get stats for all categories in one query using aggregation // We need to sum absolute values, so we'll do it in two steps const stats = await prisma.transaction.groupBy({ by: ["categoryId"], where: { categoryId: { not: null }, }, _count: { id: true, }, }); const statsMap: Record = {}; // Get uncategorized count const uncategorizedCount = await prisma.transaction.count({ where: { categoryId: null }, }); statsMap["uncategorized"] = { count: uncategorizedCount, total: 0, }; // For each category, calculate total with absolute values for (const stat of stats) { if (stat.categoryId) { const categoryTransactions = await prisma.transaction.findMany({ where: { categoryId: stat.categoryId }, select: { amount: true }, }); const total = categoryTransactions.reduce( (sum, t) => sum + Math.abs(t.amount), 0, ); statsMap[stat.categoryId] = { count: stat._count.id, total, }; } } return statsMap; }, async getAccountsWithStats(): Promise< Array > { const accounts = await prisma.account.findMany({ include: { folder: true, }, }); // Get transaction counts for all accounts in one query const transactionCounts = await prisma.transaction.groupBy({ by: ["accountId"], _count: { id: true, }, }); const countMap = new Map(); transactionCounts.forEach((tc) => { countMap.set(tc.accountId, tc._count.id); }); return accounts.map((a): Account & { transactionCount: number } => ({ id: a.id, name: a.name, bankId: a.bankId, accountNumber: a.accountNumber, type: a.type as Account["type"], folderId: a.folderId, balance: a.balance, initialBalance: a.initialBalance, currency: a.currency, lastImport: a.lastImport, externalUrl: a.externalUrl, transactionCount: countMap.get(a.id) || 0, })); }, };