401 lines
10 KiB
TypeScript
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,
|
|
}),
|
|
);
|
|
},
|
|
};
|