From 8ffb65f59685f83b0f60bd522f372b436129b72b Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Thu, 27 Nov 2025 10:07:03 +0100 Subject: [PATCH] refactor: replace direct Prisma calls with service layer methods for banking operations --- app/api/banking/accounts/route.ts | 42 ++----------- app/api/banking/categories/route.ts | 51 +++------------ app/api/banking/folders/route.ts | 65 +++---------------- app/api/banking/route.ts | 71 +-------------------- app/api/banking/transactions/route.ts | 69 ++------------------ services/account.service.ts | 67 ++++++++++++++++++++ services/banking.service.ts | 73 +++++++++++++++++++++ services/category.service.ts | 60 ++++++++++++++++++ services/folder.service.ts | 78 +++++++++++++++++++++++ services/transaction.service.ts | 91 +++++++++++++++++++++++++++ 10 files changed, 396 insertions(+), 271 deletions(-) create mode 100644 services/account.service.ts create mode 100644 services/banking.service.ts create mode 100644 services/category.service.ts create mode 100644 services/folder.service.ts create mode 100644 services/transaction.service.ts diff --git a/app/api/banking/accounts/route.ts b/app/api/banking/accounts/route.ts index 14e463d..030aac2 100644 --- a/app/api/banking/accounts/route.ts +++ b/app/api/banking/accounts/route.ts @@ -1,24 +1,11 @@ import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" +import { accountService } from "@/services/account.service" import type { Account } from "@/lib/types" export async function POST(request: Request) { try { - const account: Omit = await request.json() - - const created = await prisma.account.create({ - data: { - name: account.name, - bankId: account.bankId, - accountNumber: account.accountNumber, - type: account.type, - folderId: account.folderId, - balance: account.balance, - currency: account.currency, - lastImport: account.lastImport, - }, - }) - + const data: Omit = await request.json() + const created = await accountService.create(data) return NextResponse.json(created) } catch (error) { console.error("Error creating account:", error) @@ -29,21 +16,7 @@ export async function POST(request: Request) { export async function PUT(request: Request) { try { const account: Account = await request.json() - - const updated = await prisma.account.update({ - where: { id: account.id }, - data: { - name: account.name, - bankId: account.bankId, - accountNumber: account.accountNumber, - type: account.type, - folderId: account.folderId, - balance: account.balance, - currency: account.currency, - lastImport: account.lastImport, - }, - }) - + const updated = await accountService.update(account.id, account) return NextResponse.json(updated) } catch (error) { console.error("Error updating account:", error) @@ -60,15 +33,10 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: "Account ID is required" }, { status: 400 }) } - // Transactions will be deleted automatically due to onDelete: Cascade - await prisma.account.delete({ - where: { id }, - }) - + await accountService.delete(id) return NextResponse.json({ success: true }) } catch (error) { console.error("Error deleting account:", error) return NextResponse.json({ error: "Failed to delete account" }, { status: 500 }) } } - diff --git a/app/api/banking/categories/route.ts b/app/api/banking/categories/route.ts index 13d251c..53d30ad 100644 --- a/app/api/banking/categories/route.ts +++ b/app/api/banking/categories/route.ts @@ -1,25 +1,12 @@ import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" +import { categoryService } from "@/services/category.service" import type { Category } from "@/lib/types" export async function POST(request: Request) { try { - const category: Omit = await request.json() - - const created = await prisma.category.create({ - data: { - name: category.name, - color: category.color, - icon: category.icon, - keywords: JSON.stringify(category.keywords), - parentId: category.parentId, - }, - }) - - return NextResponse.json({ - ...created, - keywords: JSON.parse(created.keywords), - }) + const data: Omit = await request.json() + const created = await categoryService.create(data) + return NextResponse.json(created) } catch (error) { console.error("Error creating category:", error) return NextResponse.json({ error: "Failed to create category" }, { status: 500 }) @@ -29,22 +16,8 @@ export async function POST(request: Request) { export async function PUT(request: Request) { try { const category: Category = await request.json() - - const updated = await prisma.category.update({ - where: { id: category.id }, - data: { - name: category.name, - color: category.color, - icon: category.icon, - keywords: JSON.stringify(category.keywords), - parentId: category.parentId, - }, - }) - - return NextResponse.json({ - ...updated, - keywords: JSON.parse(updated.keywords), - }) + const updated = await categoryService.update(category.id, category) + return NextResponse.json(updated) } catch (error) { console.error("Error updating category:", error) return NextResponse.json({ error: "Failed to update category" }, { status: 500 }) @@ -60,20 +33,10 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: "Category ID is required" }, { status: 400 }) } - // Remove category from transactions (set to null) - await prisma.transaction.updateMany({ - where: { categoryId: id }, - data: { categoryId: null }, - }) - - await prisma.category.delete({ - where: { id }, - }) - + await categoryService.delete(id) return NextResponse.json({ success: true }) } catch (error) { console.error("Error deleting category:", error) return NextResponse.json({ error: "Failed to delete category" }, { status: 500 }) } } - diff --git a/app/api/banking/folders/route.ts b/app/api/banking/folders/route.ts index e1f91f6..304051b 100644 --- a/app/api/banking/folders/route.ts +++ b/app/api/banking/folders/route.ts @@ -1,20 +1,11 @@ import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" +import { folderService, FolderNotFoundError } from "@/services/folder.service" import type { Folder } from "@/lib/types" export async function POST(request: Request) { try { - const folder: Omit = await request.json() - - const created = await prisma.folder.create({ - data: { - name: folder.name, - parentId: folder.parentId, - color: folder.color, - icon: folder.icon, - }, - }) - + const data: Omit = await request.json() + const created = await folderService.create(data) return NextResponse.json(created) } catch (error) { console.error("Error creating folder:", error) @@ -25,17 +16,7 @@ export async function POST(request: Request) { export async function PUT(request: Request) { try { const folder: Folder = await request.json() - - const updated = await prisma.folder.update({ - where: { id: folder.id }, - data: { - name: folder.name, - parentId: folder.parentId, - color: folder.color, - icon: folder.icon, - }, - }) - + const updated = await folderService.update(folder.id, folder) return NextResponse.json(updated) } catch (error) { console.error("Error updating folder:", error) @@ -52,43 +33,13 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: "Folder ID is required" }, { status: 400 }) } - const folder = await prisma.folder.findUnique({ - where: { id }, - include: { children: true }, - }) - - if (!folder) { - return NextResponse.json({ error: "Folder not found" }, { status: 404 }) - } - - // Move accounts to root (null) - await prisma.account.updateMany({ - where: { folderId: id }, - data: { folderId: null }, - }) - - // Move subfolders to parent - if (folder.parentId) { - await prisma.folder.updateMany({ - where: { parentId: id }, - data: { parentId: folder.parentId }, - }) - } else { - // If no parent, move to null (root) - await prisma.folder.updateMany({ - where: { parentId: id }, - data: { parentId: null }, - }) - } - - await prisma.folder.delete({ - where: { id }, - }) - + await folderService.delete(id) return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof FolderNotFoundError) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }) + } console.error("Error deleting folder:", error) return NextResponse.json({ error: "Failed to delete folder" }, { status: 500 }) } } - diff --git a/app/api/banking/route.ts b/app/api/banking/route.ts index 406281b..cfdb4d0 100644 --- a/app/api/banking/route.ts +++ b/app/api/banking/route.ts @@ -1,79 +1,12 @@ import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" -import type { BankingData } from "@/lib/types" +import { bankingService } from "@/services/banking.service" export async function GET() { try { - const [accounts, transactions, folders, categories, categoryRules] = await Promise.all([ - prisma.account.findMany({ - include: { - folder: true, - }, - }), - prisma.transaction.findMany({ - include: { - account: true, - category: true, - }, - }), - prisma.folder.findMany(), - prisma.category.findMany(), - prisma.categoryRule.findMany(), - ]) - - // Transform Prisma models to match our types - const data: BankingData = { - accounts: accounts.map((a) => ({ - id: a.id, - name: a.name, - bankId: a.bankId, - accountNumber: a.accountNumber, - type: a.type as "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER", - folderId: a.folderId, - balance: a.balance, - currency: a.currency, - lastImport: a.lastImport, - })), - transactions: transactions.map((t) => ({ - id: t.id, - accountId: t.accountId, - date: t.date, - amount: t.amount, - description: t.description, - type: t.type as "DEBIT" | "CREDIT", - categoryId: t.categoryId, - isReconciled: t.isReconciled, - fitId: t.fitId, - memo: t.memo ?? undefined, - checkNum: t.checkNum ?? undefined, - })), - folders: folders.map((f) => ({ - id: f.id, - name: f.name, - parentId: f.parentId, - color: f.color, - icon: f.icon, - })), - categories: categories.map((c) => ({ - id: c.id, - name: c.name, - color: c.color, - icon: c.icon, - keywords: JSON.parse(c.keywords) as string[], - parentId: c.parentId, - })), - categoryRules: categoryRules.map((r) => ({ - id: r.id, - categoryId: r.categoryId, - pattern: r.pattern, - isRegex: r.isRegex, - })), - } - + const data = await bankingService.getAllData() return NextResponse.json(data) } catch (error) { console.error("Error fetching banking data:", error) return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 }) } } - diff --git a/app/api/banking/transactions/route.ts b/app/api/banking/transactions/route.ts index e3bca1d..8494761 100644 --- a/app/api/banking/transactions/route.ts +++ b/app/api/banking/transactions/route.ts @@ -1,51 +1,12 @@ import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" +import { transactionService } from "@/services/transaction.service" import type { Transaction } from "@/lib/types" export async function POST(request: Request) { try { const transactions: Transaction[] = await request.json() - - // Filter out duplicates based on fitId - const existingTransactions = await prisma.transaction.findMany({ - where: { - accountId: { in: transactions.map((t) => t.accountId) }, - fitId: { in: transactions.map((t) => t.fitId) }, - }, - select: { - accountId: true, - fitId: true, - }, - }) - - const existingSet = new Set( - existingTransactions.map((t) => `${t.accountId}-${t.fitId}`), - ) - - const newTransactions = transactions.filter( - (t) => !existingSet.has(`${t.accountId}-${t.fitId}`), - ) - - if (newTransactions.length === 0) { - return NextResponse.json({ count: 0, transactions: [] }) - } - - const created = await prisma.transaction.createMany({ - data: newTransactions.map((t) => ({ - accountId: t.accountId, - date: t.date, - amount: t.amount, - description: t.description, - type: t.type, - categoryId: t.categoryId, - isReconciled: t.isReconciled, - fitId: t.fitId, - memo: t.memo, - checkNum: t.checkNum, - })), - }) - - return NextResponse.json({ count: created.count, transactions: newTransactions }) + const result = await transactionService.createMany(transactions) + return NextResponse.json(result) } catch (error) { console.error("Error creating transactions:", error) return NextResponse.json({ error: "Failed to create transactions" }, { status: 500 }) @@ -55,23 +16,7 @@ export async function POST(request: Request) { export async function PUT(request: Request) { try { const transaction: Transaction = await request.json() - - const updated = await prisma.transaction.update({ - where: { id: transaction.id }, - data: { - accountId: transaction.accountId, - date: transaction.date, - amount: transaction.amount, - description: transaction.description, - type: transaction.type, - categoryId: transaction.categoryId, - isReconciled: transaction.isReconciled, - fitId: transaction.fitId, - memo: transaction.memo, - checkNum: transaction.checkNum, - }, - }) - + const updated = await transactionService.update(transaction.id, transaction) return NextResponse.json(updated) } catch (error) { console.error("Error updating transaction:", error) @@ -88,14 +33,10 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: "Transaction ID is required" }, { status: 400 }) } - await prisma.transaction.delete({ - where: { id }, - }) - + await transactionService.delete(id) return NextResponse.json({ success: true }) } catch (error) { console.error("Error deleting transaction:", error) return NextResponse.json({ error: "Failed to delete transaction" }, { status: 500 }) } } - diff --git a/services/account.service.ts b/services/account.service.ts new file mode 100644 index 0000000..81bd03e --- /dev/null +++ b/services/account.service.ts @@ -0,0 +1,67 @@ +import { prisma } from "@/lib/prisma" +import type { Account } from "@/lib/types" + +export const accountService = { + async create(data: Omit): Promise { + const created = await prisma.account.create({ + data: { + name: data.name, + bankId: data.bankId, + accountNumber: data.accountNumber, + type: data.type, + folderId: data.folderId, + balance: data.balance, + currency: data.currency, + lastImport: data.lastImport, + }, + }) + + return { + id: created.id, + name: created.name, + bankId: created.bankId, + accountNumber: created.accountNumber, + type: created.type as Account["type"], + folderId: created.folderId, + balance: created.balance, + currency: created.currency, + lastImport: created.lastImport, + } + }, + + async update(id: string, data: Partial>): Promise { + const updated = await prisma.account.update({ + where: { id }, + data: { + name: data.name, + bankId: data.bankId, + accountNumber: data.accountNumber, + type: data.type, + folderId: data.folderId, + balance: data.balance, + currency: data.currency, + lastImport: data.lastImport, + }, + }) + + return { + id: updated.id, + name: updated.name, + bankId: updated.bankId, + accountNumber: updated.accountNumber, + type: updated.type as Account["type"], + folderId: updated.folderId, + balance: updated.balance, + currency: updated.currency, + lastImport: updated.lastImport, + } + }, + + async delete(id: string): Promise { + // Transactions will be deleted automatically due to onDelete: Cascade + await prisma.account.delete({ + where: { id }, + }) + }, +} + diff --git a/services/banking.service.ts b/services/banking.service.ts new file mode 100644 index 0000000..69d7883 --- /dev/null +++ b/services/banking.service.ts @@ -0,0 +1,73 @@ +import { prisma } from "@/lib/prisma" +import type { BankingData, Account, Transaction, Folder, Category, CategoryRule } from "@/lib/types" + +export const bankingService = { + async getAllData(): Promise { + const [accounts, transactions, folders, categories, categoryRules] = await Promise.all([ + prisma.account.findMany({ + include: { + folder: true, + }, + }), + prisma.transaction.findMany({ + include: { + account: true, + category: true, + }, + }), + prisma.folder.findMany(), + prisma.category.findMany(), + prisma.categoryRule.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, + currency: a.currency, + lastImport: a.lastImport, + })), + 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, + })), + categoryRules: categoryRules.map((r): CategoryRule => ({ + id: r.id, + categoryId: r.categoryId, + pattern: r.pattern, + isRegex: r.isRegex, + })), + } + }, +} + diff --git a/services/category.service.ts b/services/category.service.ts new file mode 100644 index 0000000..09a8853 --- /dev/null +++ b/services/category.service.ts @@ -0,0 +1,60 @@ +import { prisma } from "@/lib/prisma" +import type { Category } from "@/lib/types" + +export const categoryService = { + async create(data: Omit): Promise { + const created = await prisma.category.create({ + data: { + name: data.name, + color: data.color, + icon: data.icon, + keywords: JSON.stringify(data.keywords), + parentId: data.parentId, + }, + }) + + return { + id: created.id, + name: created.name, + color: created.color, + icon: created.icon, + keywords: JSON.parse(created.keywords) as string[], + parentId: created.parentId, + } + }, + + async update(id: string, data: Partial>): Promise { + const updated = await prisma.category.update({ + where: { id }, + data: { + name: data.name, + color: data.color, + icon: data.icon, + keywords: data.keywords ? JSON.stringify(data.keywords) : undefined, + parentId: data.parentId, + }, + }) + + return { + id: updated.id, + name: updated.name, + color: updated.color, + icon: updated.icon, + keywords: JSON.parse(updated.keywords) as string[], + parentId: updated.parentId, + } + }, + + async delete(id: string): Promise { + // Business rule: Remove category from transactions (set to null) + await prisma.transaction.updateMany({ + where: { categoryId: id }, + data: { categoryId: null }, + }) + + await prisma.category.delete({ + where: { id }, + }) + }, +} + diff --git a/services/folder.service.ts b/services/folder.service.ts new file mode 100644 index 0000000..f28dc4a --- /dev/null +++ b/services/folder.service.ts @@ -0,0 +1,78 @@ +import { prisma } from "@/lib/prisma" +import type { Folder } from "@/lib/types" + +export class FolderNotFoundError extends Error { + constructor(id: string) { + super(`Folder not found: ${id}`) + this.name = "FolderNotFoundError" + } +} + +export const folderService = { + async create(data: Omit): Promise { + const created = await prisma.folder.create({ + data: { + name: data.name, + parentId: data.parentId, + color: data.color, + icon: data.icon, + }, + }) + + return { + id: created.id, + name: created.name, + parentId: created.parentId, + color: created.color, + icon: created.icon, + } + }, + + async update(id: string, data: Partial>): Promise { + const updated = await prisma.folder.update({ + where: { id }, + data: { + name: data.name, + parentId: data.parentId, + color: data.color, + icon: data.icon, + }, + }) + + return { + id: updated.id, + name: updated.name, + parentId: updated.parentId, + color: updated.color, + icon: updated.icon, + } + }, + + async delete(id: string): Promise { + const folder = await prisma.folder.findUnique({ + where: { id }, + include: { children: true }, + }) + + if (!folder) { + throw new FolderNotFoundError(id) + } + + // Business rule: Move accounts to root (null folderId) + await prisma.account.updateMany({ + where: { folderId: id }, + data: { folderId: null }, + }) + + // Business rule: Move subfolders to parent (or root if no parent) + await prisma.folder.updateMany({ + where: { parentId: id }, + data: { parentId: folder.parentId }, + }) + + await prisma.folder.delete({ + where: { id }, + }) + }, +} + diff --git a/services/transaction.service.ts b/services/transaction.service.ts new file mode 100644 index 0000000..06697c3 --- /dev/null +++ b/services/transaction.service.ts @@ -0,0 +1,91 @@ +import { prisma } from "@/lib/prisma" +import type { Transaction } from "@/lib/types" + +export interface CreateManyResult { + count: number + transactions: Transaction[] +} + +export const transactionService = { + async createMany(transactions: Transaction[]): Promise { + // Filter out duplicates based on fitId (business rule) + const existingTransactions = await prisma.transaction.findMany({ + where: { + accountId: { in: transactions.map((t) => t.accountId) }, + fitId: { in: transactions.map((t) => t.fitId) }, + }, + select: { + accountId: true, + fitId: true, + }, + }) + + const existingSet = new Set( + existingTransactions.map((t) => `${t.accountId}-${t.fitId}`), + ) + + const newTransactions = transactions.filter( + (t) => !existingSet.has(`${t.accountId}-${t.fitId}`), + ) + + if (newTransactions.length === 0) { + return { count: 0, transactions: [] } + } + + const created = await prisma.transaction.createMany({ + data: newTransactions.map((t) => ({ + accountId: t.accountId, + date: t.date, + amount: t.amount, + description: t.description, + type: t.type, + categoryId: t.categoryId, + isReconciled: t.isReconciled, + fitId: t.fitId, + memo: t.memo, + checkNum: t.checkNum, + })), + }) + + return { count: created.count, transactions: newTransactions } + }, + + async update(id: string, data: Partial>): Promise { + const updated = await prisma.transaction.update({ + where: { id }, + data: { + accountId: data.accountId, + date: data.date, + amount: data.amount, + description: data.description, + type: data.type, + categoryId: data.categoryId, + isReconciled: data.isReconciled, + fitId: data.fitId, + memo: data.memo, + checkNum: data.checkNum, + }, + }) + + return { + id: updated.id, + accountId: updated.accountId, + date: updated.date, + amount: updated.amount, + description: updated.description, + type: updated.type as Transaction["type"], + categoryId: updated.categoryId, + isReconciled: updated.isReconciled, + fitId: updated.fitId, + memo: updated.memo ?? undefined, + checkNum: updated.checkNum ?? undefined, + } + }, + + async delete(id: string): Promise { + await prisma.transaction.delete({ + where: { id }, + }) + }, +} +