Files
fintrack/services/banking.service.ts

401 lines
10 KiB
TypeScript

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<BankingData> {
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<TransactionsPaginatedResult> {
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<string, { count: number; total: number }>> {
// 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<string, { count: number; total: number }> = {};
// 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<Account & { transactionCount: number }>
> {
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<string, number>();
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,
}),
);
},
};