Add backend API for personal finance management application
- Introduced a comprehensive backend API using TypeScript, Fastify, and PostgreSQL. - Added essential files including architecture documentation, environment configuration, and Docker setup. - Implemented RESTful routes for managing assets, liabilities, clients, invoices, and cashflow. - Established a robust database schema with Prisma for data management. - Integrated middleware for authentication and error handling. - Created service and repository layers to adhere to SOLID principles and clean architecture. - Included example environment variables for development, staging, and production setups.
This commit is contained in:
91
backend-api/src/services/AssetService.ts
Normal file
91
backend-api/src/services/AssetService.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {Asset, AssetType} from '@prisma/client';
|
||||
import {AssetRepository} from '../repositories/AssetRepository';
|
||||
import {NotFoundError, ForbiddenError, ValidationError} from '../utils/errors';
|
||||
|
||||
interface CreateAssetDTO {
|
||||
name: string;
|
||||
type: AssetType;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface UpdateAssetDTO {
|
||||
name?: string;
|
||||
type?: AssetType;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset Service
|
||||
* Implements Single Responsibility: Handles asset business logic
|
||||
* Implements Open/Closed: Extensible for new asset-related features
|
||||
*/
|
||||
export class AssetService {
|
||||
constructor(private assetRepository: AssetRepository) {}
|
||||
|
||||
async getAll(userId: string): Promise<Asset[]> {
|
||||
return this.assetRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
async getById(id: string, userId: string): Promise<Asset> {
|
||||
const asset = await this.assetRepository.findByIdAndUser(id, userId);
|
||||
if (!asset) {
|
||||
throw new NotFoundError('Asset not found');
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateAssetDTO): Promise<Asset> {
|
||||
this.validateAssetData(data);
|
||||
|
||||
return this.assetRepository.create({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
value: data.value,
|
||||
user: {connect: {id: userId}},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: UpdateAssetDTO): Promise<Asset> {
|
||||
const asset = await this.assetRepository.findByIdAndUser(id, userId);
|
||||
if (!asset) {
|
||||
throw new NotFoundError('Asset not found');
|
||||
}
|
||||
|
||||
if (data.value !== undefined || data.name !== undefined || data.type !== undefined) {
|
||||
this.validateAssetData({
|
||||
name: data.name || asset.name,
|
||||
type: data.type || asset.type,
|
||||
value: data.value !== undefined ? data.value : asset.value,
|
||||
});
|
||||
}
|
||||
|
||||
return this.assetRepository.update(id, data);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const asset = await this.assetRepository.findByIdAndUser(id, userId);
|
||||
if (!asset) {
|
||||
throw new NotFoundError('Asset not found');
|
||||
}
|
||||
|
||||
await this.assetRepository.delete(id);
|
||||
}
|
||||
|
||||
async getTotalValue(userId: string): Promise<number> {
|
||||
return this.assetRepository.getTotalValue(userId);
|
||||
}
|
||||
|
||||
private validateAssetData(data: CreateAssetDTO): void {
|
||||
if (!data.name || data.name.trim().length === 0) {
|
||||
throw new ValidationError('Asset name is required');
|
||||
}
|
||||
|
||||
if (data.value < 0) {
|
||||
throw new ValidationError('Asset value cannot be negative');
|
||||
}
|
||||
|
||||
if (!Object.values(AssetType).includes(data.type)) {
|
||||
throw new ValidationError('Invalid asset type');
|
||||
}
|
||||
}
|
||||
}
|
||||
68
backend-api/src/services/AuthService.ts
Normal file
68
backend-api/src/services/AuthService.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {User} from '@prisma/client';
|
||||
import {UserRepository} from '../repositories/UserRepository';
|
||||
import {PasswordService} from '../utils/password';
|
||||
import {UnauthorizedError, ValidationError, ConflictError} from '../utils/errors';
|
||||
import {DebtCategoryService} from './DebtCategoryService';
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Implements Single Responsibility: Handles authentication logic
|
||||
* Implements Dependency Inversion: Depends on UserRepository abstraction
|
||||
*/
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userRepository: UserRepository,
|
||||
private debtCategoryService: DebtCategoryService
|
||||
) {}
|
||||
|
||||
async register(email: string, password: string, name: string): Promise<Omit<User, 'password'>> {
|
||||
// Validate password
|
||||
const passwordValidation = PasswordService.validate(password);
|
||||
if (!passwordValidation.valid) {
|
||||
throw new ValidationError(passwordValidation.errors.join(', '));
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await this.userRepository.findByEmail(email);
|
||||
if (existingUser) {
|
||||
throw new ConflictError('Email already registered');
|
||||
}
|
||||
|
||||
// Hash password and create user
|
||||
const hashedPassword = await PasswordService.hash(password);
|
||||
const user = await this.userRepository.create({
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
});
|
||||
|
||||
// Create default debt categories for new user
|
||||
await this.debtCategoryService.createDefaultCategories(user.id);
|
||||
|
||||
// Return user without password
|
||||
const {password: _, ...userWithoutPassword} = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<User> {
|
||||
const user = await this.userRepository.findByEmail(email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedError('Invalid email or password');
|
||||
}
|
||||
|
||||
const passwordValid = await PasswordService.compare(password, user.password);
|
||||
if (!passwordValid) {
|
||||
throw new UnauthorizedError('Invalid email or password');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<Omit<User, 'password'> | null> {
|
||||
const user = await this.userRepository.findById(id);
|
||||
if (!user) return null;
|
||||
|
||||
const {password: _, ...userWithoutPassword} = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
}
|
||||
162
backend-api/src/services/CashflowService.ts
Normal file
162
backend-api/src/services/CashflowService.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {IncomeSource, Expense, Transaction} from '@prisma/client';
|
||||
import {
|
||||
IncomeSourceRepository,
|
||||
ExpenseRepository,
|
||||
TransactionRepository,
|
||||
} from '../repositories/CashflowRepository';
|
||||
import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors';
|
||||
|
||||
export interface CreateIncomeSourceDTO {
|
||||
name: string;
|
||||
amount: number;
|
||||
frequency: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateExpenseDTO {
|
||||
name: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
frequency: string;
|
||||
dueDate?: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateTransactionDTO {
|
||||
type: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
date: Date;
|
||||
description?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for Cashflow business logic
|
||||
*/
|
||||
export class CashflowService {
|
||||
constructor(
|
||||
private incomeRepository: IncomeSourceRepository,
|
||||
private expenseRepository: ExpenseRepository,
|
||||
private transactionRepository: TransactionRepository
|
||||
) {}
|
||||
|
||||
// Income Source methods
|
||||
async createIncome(userId: string, data: CreateIncomeSourceDTO): Promise<IncomeSource> {
|
||||
if (data.amount <= 0) throw new ValidationError('Amount must be greater than 0');
|
||||
|
||||
return this.incomeRepository.create({
|
||||
...data,
|
||||
user: {connect: {id: userId}},
|
||||
});
|
||||
}
|
||||
|
||||
async getAllIncome(userId: string): Promise<IncomeSource[]> {
|
||||
return this.incomeRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
async getIncomeById(id: string, userId: string): Promise<IncomeSource> {
|
||||
const income = await this.incomeRepository.findById(id);
|
||||
if (!income) throw new NotFoundError('Income source not found');
|
||||
if (income.userId !== userId) throw new ForbiddenError('Access denied');
|
||||
return income;
|
||||
}
|
||||
|
||||
async updateIncome(id: string, userId: string, data: Partial<CreateIncomeSourceDTO>): Promise<IncomeSource> {
|
||||
await this.getIncomeById(id, userId);
|
||||
if (data.amount !== undefined && data.amount <= 0) {
|
||||
throw new ValidationError('Amount must be greater than 0');
|
||||
}
|
||||
return this.incomeRepository.update(id, data);
|
||||
}
|
||||
|
||||
async deleteIncome(id: string, userId: string): Promise<void> {
|
||||
await this.getIncomeById(id, userId);
|
||||
await this.incomeRepository.delete(id);
|
||||
}
|
||||
|
||||
async getTotalMonthlyIncome(userId: string): Promise<number> {
|
||||
return this.incomeRepository.getTotalMonthlyIncome(userId);
|
||||
}
|
||||
|
||||
// Expense methods
|
||||
async createExpense(userId: string, data: CreateExpenseDTO): Promise<Expense> {
|
||||
if (data.amount <= 0) throw new ValidationError('Amount must be greater than 0');
|
||||
|
||||
return this.expenseRepository.create({
|
||||
...data,
|
||||
user: {connect: {id: userId}},
|
||||
});
|
||||
}
|
||||
|
||||
async getAllExpenses(userId: string): Promise<Expense[]> {
|
||||
return this.expenseRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
async getExpenseById(id: string, userId: string): Promise<Expense> {
|
||||
const expense = await this.expenseRepository.findById(id);
|
||||
if (!expense) throw new NotFoundError('Expense not found');
|
||||
if (expense.userId !== userId) throw new ForbiddenError('Access denied');
|
||||
return expense;
|
||||
}
|
||||
|
||||
async updateExpense(id: string, userId: string, data: Partial<CreateExpenseDTO>): Promise<Expense> {
|
||||
await this.getExpenseById(id, userId);
|
||||
if (data.amount !== undefined && data.amount <= 0) {
|
||||
throw new ValidationError('Amount must be greater than 0');
|
||||
}
|
||||
return this.expenseRepository.update(id, data);
|
||||
}
|
||||
|
||||
async deleteExpense(id: string, userId: string): Promise<void> {
|
||||
await this.getExpenseById(id, userId);
|
||||
await this.expenseRepository.delete(id);
|
||||
}
|
||||
|
||||
async getTotalMonthlyExpenses(userId: string): Promise<number> {
|
||||
return this.expenseRepository.getTotalMonthlyExpenses(userId);
|
||||
}
|
||||
|
||||
async getExpensesByCategory(userId: string): Promise<Record<string, Expense[]>> {
|
||||
return this.expenseRepository.getByCategory(userId);
|
||||
}
|
||||
|
||||
// Transaction methods
|
||||
async createTransaction(userId: string, data: CreateTransactionDTO): Promise<Transaction> {
|
||||
if (data.amount <= 0) throw new ValidationError('Amount must be greater than 0');
|
||||
if (data.date > new Date()) throw new ValidationError('Date cannot be in the future');
|
||||
|
||||
return this.transactionRepository.create({
|
||||
...data,
|
||||
user: {connect: {id: userId}},
|
||||
});
|
||||
}
|
||||
|
||||
async getAllTransactions(userId: string): Promise<Transaction[]> {
|
||||
return this.transactionRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
async getTransactionById(id: string, userId: string): Promise<Transaction> {
|
||||
const transaction = await this.transactionRepository.findById(id);
|
||||
if (!transaction) throw new NotFoundError('Transaction not found');
|
||||
if (transaction.userId !== userId) throw new ForbiddenError('Access denied');
|
||||
return transaction;
|
||||
}
|
||||
|
||||
async deleteTransaction(id: string, userId: string): Promise<void> {
|
||||
await this.getTransactionById(id, userId);
|
||||
await this.transactionRepository.delete(id);
|
||||
}
|
||||
|
||||
async getTransactionsByDateRange(userId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
|
||||
return this.transactionRepository.getByDateRange(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
async getTransactionsByType(userId: string, type: string): Promise<Transaction[]> {
|
||||
return this.transactionRepository.getByType(userId, type);
|
||||
}
|
||||
|
||||
async getCashflowSummary(userId: string, startDate: Date, endDate: Date) {
|
||||
return this.transactionRepository.getCashflowSummary(userId, startDate, endDate);
|
||||
}
|
||||
}
|
||||
148
backend-api/src/services/ClientService.ts
Normal file
148
backend-api/src/services/ClientService.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {Client} from '@prisma/client';
|
||||
import {ClientRepository} from '../repositories/ClientRepository';
|
||||
import {NotFoundError, ValidationError, ForbiddenError, ConflictError} from '../utils/errors';
|
||||
|
||||
export interface CreateClientDTO {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateClientDTO {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for Client business logic
|
||||
* Implements Single Responsibility Principle - handles only business logic
|
||||
* Implements Dependency Inversion - depends on repository abstraction
|
||||
*/
|
||||
export class ClientService {
|
||||
constructor(private clientRepository: ClientRepository) {}
|
||||
|
||||
/**
|
||||
* Create a new client
|
||||
*/
|
||||
async create(userId: string, data: CreateClientDTO): Promise<Client> {
|
||||
this.validateClientData(data);
|
||||
|
||||
// Check for duplicate email
|
||||
const existing = await this.clientRepository.findByEmail(userId, data.email);
|
||||
if (existing) {
|
||||
throw new ConflictError('A client with this email already exists');
|
||||
}
|
||||
|
||||
return this.clientRepository.create({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
address: data.address,
|
||||
notes: data.notes,
|
||||
user: {
|
||||
connect: {id: userId},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clients for a user
|
||||
*/
|
||||
async getAllByUser(userId: string): Promise<Client[]> {
|
||||
return this.clientRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients with statistics
|
||||
*/
|
||||
async getWithStats(userId: string): Promise<any[]> {
|
||||
return this.clientRepository.getWithStats(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single client by ID
|
||||
*/
|
||||
async getById(id: string, userId: string): Promise<Client> {
|
||||
const client = await this.clientRepository.findById(id);
|
||||
|
||||
if (!client) {
|
||||
throw new NotFoundError('Client not found');
|
||||
}
|
||||
|
||||
// Ensure user owns this client
|
||||
if (client.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a client
|
||||
*/
|
||||
async update(id: string, userId: string, data: UpdateClientDTO): Promise<Client> {
|
||||
// Verify ownership
|
||||
await this.getById(id, userId);
|
||||
|
||||
if (data.email) {
|
||||
this.validateClientData(data as CreateClientDTO);
|
||||
|
||||
// Check for duplicate email (excluding current client)
|
||||
const existing = await this.clientRepository.findByEmail(userId, data.email);
|
||||
if (existing && existing.id !== id) {
|
||||
throw new ConflictError('A client with this email already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return this.clientRepository.update(id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a client
|
||||
*/
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
// Verify ownership
|
||||
const client = await this.getById(id, userId);
|
||||
|
||||
// Check if client has invoices - we still allow deletion due to cascade
|
||||
// but you might want to prevent deletion if there are invoices
|
||||
// Uncomment below to prevent deletion:
|
||||
// if (client.invoices && client.invoices.length > 0) {
|
||||
// throw new ValidationError('Cannot delete client with existing invoices');
|
||||
// }
|
||||
|
||||
await this.clientRepository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total revenue from all clients
|
||||
*/
|
||||
async getTotalRevenue(userId: string): Promise<number> {
|
||||
return this.clientRepository.getTotalRevenue(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate client data
|
||||
*/
|
||||
private validateClientData(data: CreateClientDTO | UpdateClientDTO): void {
|
||||
if (data.email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(data.email)) {
|
||||
throw new ValidationError('Invalid email format');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.phone) {
|
||||
// Basic phone validation - at least 10 digits
|
||||
const phoneDigits = data.phone.replace(/\D/g, '');
|
||||
if (phoneDigits.length < 10) {
|
||||
throw new ValidationError('Phone number must contain at least 10 digits');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
backend-api/src/services/DashboardService.ts
Normal file
96
backend-api/src/services/DashboardService.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {AssetRepository} from '../repositories/AssetRepository';
|
||||
import {LiabilityRepository} from '../repositories/LiabilityRepository';
|
||||
import {InvoiceRepository} from '../repositories/InvoiceRepository';
|
||||
import {DebtAccountRepository} from '../repositories/DebtAccountRepository';
|
||||
import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository';
|
||||
import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository';
|
||||
|
||||
/**
|
||||
* Service for Dashboard summary data
|
||||
* Aggregates data from all financial modules
|
||||
*/
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
private assetRepository: AssetRepository,
|
||||
private liabilityRepository: LiabilityRepository,
|
||||
private invoiceRepository: InvoiceRepository,
|
||||
private debtAccountRepository: DebtAccountRepository,
|
||||
private incomeRepository: IncomeSourceRepository,
|
||||
private expenseRepository: ExpenseRepository,
|
||||
private transactionRepository: TransactionRepository,
|
||||
private snapshotRepository: NetWorthSnapshotRepository
|
||||
) {}
|
||||
|
||||
async getSummary(userId: string) {
|
||||
// Get current net worth
|
||||
const totalAssets = await this.assetRepository.getTotalValue(userId);
|
||||
const totalLiabilities = await this.liabilityRepository.getTotalValue(userId);
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
// Get latest snapshot for comparison
|
||||
const latestSnapshot = await this.snapshotRepository.getLatest(userId);
|
||||
let netWorthChange = 0;
|
||||
if (latestSnapshot) {
|
||||
netWorthChange = netWorth - latestSnapshot.netWorth;
|
||||
}
|
||||
|
||||
// Get invoice stats
|
||||
const invoiceStats = await this.invoiceRepository.getStats(userId);
|
||||
|
||||
// Get debt info
|
||||
const totalDebt = await this.debtAccountRepository.getTotalDebt(userId);
|
||||
|
||||
// Get cashflow info
|
||||
const totalMonthlyIncome = await this.incomeRepository.getTotalMonthlyIncome(userId);
|
||||
const totalMonthlyExpenses = await this.expenseRepository.getTotalMonthlyExpenses(userId);
|
||||
const monthlyCashflow = totalMonthlyIncome - totalMonthlyExpenses;
|
||||
|
||||
// Get recent transactions (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const recentCashflow = await this.transactionRepository.getCashflowSummary(
|
||||
userId,
|
||||
thirtyDaysAgo,
|
||||
new Date()
|
||||
);
|
||||
|
||||
// Get assets by type
|
||||
const assetsByType = await this.assetRepository.getByType(userId);
|
||||
const assetAllocation = Object.entries(assetsByType).map(([type, assets]) => ({
|
||||
type,
|
||||
count: assets.length,
|
||||
totalValue: assets.reduce((sum, asset) => sum + asset.currentValue, 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
netWorth: {
|
||||
current: netWorth,
|
||||
assets: totalAssets,
|
||||
liabilities: totalLiabilities,
|
||||
change: netWorthChange,
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
invoices: {
|
||||
total: invoiceStats.totalInvoices,
|
||||
paid: invoiceStats.paidInvoices,
|
||||
outstanding: invoiceStats.outstandingAmount,
|
||||
overdue: invoiceStats.overdueInvoices,
|
||||
},
|
||||
debts: {
|
||||
total: totalDebt,
|
||||
accounts: (await this.debtAccountRepository.findAllByUser(userId)).length,
|
||||
},
|
||||
cashflow: {
|
||||
monthlyIncome: totalMonthlyIncome,
|
||||
monthlyExpenses: totalMonthlyExpenses,
|
||||
monthlyNet: monthlyCashflow,
|
||||
last30Days: recentCashflow,
|
||||
},
|
||||
assets: {
|
||||
total: totalAssets,
|
||||
count: (await this.assetRepository.findAllByUser(userId)).length,
|
||||
allocation: assetAllocation,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
168
backend-api/src/services/DebtAccountService.ts
Normal file
168
backend-api/src/services/DebtAccountService.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {DebtAccount} from '@prisma/client';
|
||||
import {DebtAccountRepository} from '../repositories/DebtAccountRepository';
|
||||
import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository';
|
||||
import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors';
|
||||
|
||||
export interface CreateDebtAccountDTO {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
creditor: string;
|
||||
accountNumber?: string;
|
||||
originalBalance: number;
|
||||
currentBalance: number;
|
||||
interestRate?: number;
|
||||
minimumPayment?: number;
|
||||
dueDate?: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDebtAccountDTO {
|
||||
name?: string;
|
||||
creditor?: string;
|
||||
accountNumber?: string;
|
||||
currentBalance?: number;
|
||||
interestRate?: number;
|
||||
minimumPayment?: number;
|
||||
dueDate?: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for DebtAccount business logic
|
||||
* Implements Single Responsibility Principle - handles only business logic
|
||||
* Implements Dependency Inversion - depends on repository abstractions
|
||||
*/
|
||||
export class DebtAccountService {
|
||||
constructor(
|
||||
private accountRepository: DebtAccountRepository,
|
||||
private categoryRepository: DebtCategoryRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new debt account
|
||||
*/
|
||||
async create(userId: string, data: CreateDebtAccountDTO): Promise<DebtAccount> {
|
||||
this.validateAccountData(data);
|
||||
|
||||
// Verify category ownership
|
||||
const category = await this.categoryRepository.findById(data.categoryId);
|
||||
if (!category) {
|
||||
throw new NotFoundError('Debt category not found');
|
||||
}
|
||||
if (category.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return this.accountRepository.create({
|
||||
name: data.name,
|
||||
creditor: data.creditor,
|
||||
accountNumber: data.accountNumber,
|
||||
originalBalance: data.originalBalance,
|
||||
currentBalance: data.currentBalance,
|
||||
interestRate: data.interestRate,
|
||||
minimumPayment: data.minimumPayment,
|
||||
dueDate: data.dueDate,
|
||||
notes: data.notes,
|
||||
category: {
|
||||
connect: {id: data.categoryId},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all debt accounts for a user
|
||||
*/
|
||||
async getAllByUser(userId: string): Promise<DebtAccount[]> {
|
||||
return this.accountRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debt accounts with statistics
|
||||
*/
|
||||
async getWithStats(userId: string): Promise<any[]> {
|
||||
return this.accountRepository.getWithStats(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debt accounts by category
|
||||
*/
|
||||
async getByCategory(categoryId: string, userId: string): Promise<DebtAccount[]> {
|
||||
// Verify category ownership
|
||||
const category = await this.categoryRepository.findById(categoryId);
|
||||
if (!category) {
|
||||
throw new NotFoundError('Debt category not found');
|
||||
}
|
||||
if (category.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return this.accountRepository.findByCategory(categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single debt account by ID
|
||||
*/
|
||||
async getById(id: string, userId: string): Promise<DebtAccount> {
|
||||
const account = await this.accountRepository.findById(id);
|
||||
|
||||
if (!account) {
|
||||
throw new NotFoundError('Debt account not found');
|
||||
}
|
||||
|
||||
// Verify ownership through category
|
||||
if (account.category.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a debt account
|
||||
*/
|
||||
async update(id: string, userId: string, data: UpdateDebtAccountDTO): Promise<DebtAccount> {
|
||||
await this.getById(id, userId);
|
||||
|
||||
if (data.currentBalance !== undefined || data.interestRate !== undefined || data.minimumPayment !== undefined) {
|
||||
this.validateAccountData(data as CreateDebtAccountDTO);
|
||||
}
|
||||
|
||||
return this.accountRepository.update(id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a debt account
|
||||
*/
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.getById(id, userId);
|
||||
await this.accountRepository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total debt for a user
|
||||
*/
|
||||
async getTotalDebt(userId: string): Promise<number> {
|
||||
return this.accountRepository.getTotalDebt(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate account data
|
||||
*/
|
||||
private validateAccountData(data: CreateDebtAccountDTO | UpdateDebtAccountDTO): void {
|
||||
if ('originalBalance' in data && data.originalBalance < 0) {
|
||||
throw new ValidationError('Original balance cannot be negative');
|
||||
}
|
||||
|
||||
if (data.currentBalance !== undefined && data.currentBalance < 0) {
|
||||
throw new ValidationError('Current balance cannot be negative');
|
||||
}
|
||||
|
||||
if (data.interestRate !== undefined && (data.interestRate < 0 || data.interestRate > 100)) {
|
||||
throw new ValidationError('Interest rate must be between 0 and 100');
|
||||
}
|
||||
|
||||
if (data.minimumPayment !== undefined && data.minimumPayment < 0) {
|
||||
throw new ValidationError('Minimum payment cannot be negative');
|
||||
}
|
||||
}
|
||||
}
|
||||
156
backend-api/src/services/DebtCategoryService.ts
Normal file
156
backend-api/src/services/DebtCategoryService.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {DebtCategory} from '@prisma/client';
|
||||
import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository';
|
||||
import {NotFoundError, ValidationError, ForbiddenError, ConflictError} from '../utils/errors';
|
||||
|
||||
export interface CreateDebtCategoryDTO {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDebtCategoryDTO {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for DebtCategory business logic
|
||||
* Implements Single Responsibility Principle - handles only business logic
|
||||
* Implements Dependency Inversion - depends on repository abstraction
|
||||
*/
|
||||
export class DebtCategoryService {
|
||||
constructor(private categoryRepository: DebtCategoryRepository) {}
|
||||
|
||||
/**
|
||||
* Create default debt categories for a new user
|
||||
*/
|
||||
async createDefaultCategories(userId: string): Promise<DebtCategory[]> {
|
||||
const defaultCategories = [
|
||||
{name: 'Credit Cards', description: 'Credit card debts', color: '#ef4444'},
|
||||
{name: 'Student Loans', description: 'Student loan debts', color: '#3b82f6'},
|
||||
{name: 'Auto Loans', description: 'Car and vehicle loans', color: '#10b981'},
|
||||
{name: 'Mortgages', description: 'Home mortgages', color: '#f59e0b'},
|
||||
{name: 'Personal Loans', description: 'Personal loan debts', color: '#8b5cf6'},
|
||||
{name: 'Other', description: 'Other debt types', color: '#6b7280'},
|
||||
];
|
||||
|
||||
const categories: DebtCategory[] = [];
|
||||
|
||||
for (const category of defaultCategories) {
|
||||
const created = await this.categoryRepository.create({
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
color: category.color,
|
||||
user: {
|
||||
connect: {id: userId},
|
||||
},
|
||||
});
|
||||
categories.push(created);
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new debt category
|
||||
*/
|
||||
async create(userId: string, data: CreateDebtCategoryDTO): Promise<DebtCategory> {
|
||||
this.validateCategoryData(data);
|
||||
|
||||
// Check for duplicate name
|
||||
const existing = await this.categoryRepository.findByName(userId, data.name);
|
||||
if (existing) {
|
||||
throw new ConflictError('A category with this name already exists');
|
||||
}
|
||||
|
||||
return this.categoryRepository.create({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
user: {
|
||||
connect: {id: userId},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories for a user
|
||||
*/
|
||||
async getAllByUser(userId: string): Promise<DebtCategory[]> {
|
||||
return this.categoryRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories with statistics
|
||||
*/
|
||||
async getWithStats(userId: string): Promise<any[]> {
|
||||
return this.categoryRepository.getWithStats(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single category by ID
|
||||
*/
|
||||
async getById(id: string, userId: string): Promise<DebtCategory> {
|
||||
const category = await this.categoryRepository.findById(id);
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundError('Debt category not found');
|
||||
}
|
||||
|
||||
if (category.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a category
|
||||
*/
|
||||
async update(id: string, userId: string, data: UpdateDebtCategoryDTO): Promise<DebtCategory> {
|
||||
await this.getById(id, userId);
|
||||
|
||||
if (data.name) {
|
||||
this.validateCategoryData(data as CreateDebtCategoryDTO);
|
||||
|
||||
// Check for duplicate name (excluding current category)
|
||||
const existing = await this.categoryRepository.findByName(userId, data.name);
|
||||
if (existing && existing.id !== id) {
|
||||
throw new ConflictError('A category with this name already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return this.categoryRepository.update(id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a category
|
||||
*/
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const category = await this.getById(id, userId);
|
||||
|
||||
// Check if category has accounts
|
||||
// Note: Cascade delete will remove all accounts and payments
|
||||
// You might want to prevent deletion if there are accounts
|
||||
// Uncomment below to prevent deletion:
|
||||
// if (category.accounts && category.accounts.length > 0) {
|
||||
// throw new ValidationError('Cannot delete category with existing accounts');
|
||||
// }
|
||||
|
||||
await this.categoryRepository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate category data
|
||||
*/
|
||||
private validateCategoryData(data: CreateDebtCategoryDTO | UpdateDebtCategoryDTO): void {
|
||||
if (data.color) {
|
||||
// Validate hex color format
|
||||
const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
|
||||
if (!hexColorRegex.test(data.color)) {
|
||||
throw new ValidationError('Color must be a valid hex color (e.g., #FF5733)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
backend-api/src/services/DebtPaymentService.ts
Normal file
143
backend-api/src/services/DebtPaymentService.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {DebtPayment} from '@prisma/client';
|
||||
import {DebtPaymentRepository} from '../repositories/DebtPaymentRepository';
|
||||
import {DebtAccountRepository} from '../repositories/DebtAccountRepository';
|
||||
import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors';
|
||||
|
||||
export interface CreateDebtPaymentDTO {
|
||||
accountId: string;
|
||||
amount: number;
|
||||
paymentDate: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for DebtPayment business logic
|
||||
* Implements Single Responsibility Principle - handles only business logic
|
||||
* Implements Dependency Inversion - depends on repository abstractions
|
||||
*/
|
||||
export class DebtPaymentService {
|
||||
constructor(
|
||||
private paymentRepository: DebtPaymentRepository,
|
||||
private accountRepository: DebtAccountRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new debt payment
|
||||
*/
|
||||
async create(userId: string, data: CreateDebtPaymentDTO): Promise<DebtPayment> {
|
||||
this.validatePaymentData(data);
|
||||
|
||||
// Verify account ownership
|
||||
const account = await this.accountRepository.findById(data.accountId);
|
||||
if (!account) {
|
||||
throw new NotFoundError('Debt account not found');
|
||||
}
|
||||
if (account.category.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
// Create payment
|
||||
const payment = await this.paymentRepository.create({
|
||||
amount: data.amount,
|
||||
paymentDate: data.paymentDate,
|
||||
notes: data.notes,
|
||||
account: {
|
||||
connect: {id: data.accountId},
|
||||
},
|
||||
});
|
||||
|
||||
// Update account current balance
|
||||
const newBalance = account.currentBalance - data.amount;
|
||||
await this.accountRepository.update(data.accountId, {
|
||||
currentBalance: Math.max(0, newBalance), // Don't allow negative balance
|
||||
});
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all payments for a user
|
||||
*/
|
||||
async getAllByUser(userId: string): Promise<DebtPayment[]> {
|
||||
return this.paymentRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payments by account
|
||||
*/
|
||||
async getByAccount(accountId: string, userId: string): Promise<DebtPayment[]> {
|
||||
// Verify account ownership
|
||||
const account = await this.accountRepository.findById(accountId);
|
||||
if (!account) {
|
||||
throw new NotFoundError('Debt account not found');
|
||||
}
|
||||
if (account.category.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return this.paymentRepository.findByAccount(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payments within a date range
|
||||
*/
|
||||
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<DebtPayment[]> {
|
||||
return this.paymentRepository.getByDateRange(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single payment by ID
|
||||
*/
|
||||
async getById(id: string, userId: string): Promise<DebtPayment> {
|
||||
const payment = await this.paymentRepository.findById(id);
|
||||
|
||||
if (!payment) {
|
||||
throw new NotFoundError('Debt payment not found');
|
||||
}
|
||||
|
||||
// Verify ownership through account and category
|
||||
if (payment.account.category.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a payment (and restore account balance)
|
||||
*/
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const payment = await this.getById(id, userId);
|
||||
|
||||
// Restore the payment amount to account balance
|
||||
const account = await this.accountRepository.findById(payment.accountId);
|
||||
if (account) {
|
||||
const newBalance = account.currentBalance + payment.amount;
|
||||
await this.accountRepository.update(payment.accountId, {
|
||||
currentBalance: newBalance,
|
||||
});
|
||||
}
|
||||
|
||||
await this.paymentRepository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total payments for a user
|
||||
*/
|
||||
async getTotalPayments(userId: string): Promise<number> {
|
||||
return this.paymentRepository.getTotalPaymentsByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment data
|
||||
*/
|
||||
private validatePaymentData(data: CreateDebtPaymentDTO): void {
|
||||
if (data.amount <= 0) {
|
||||
throw new ValidationError('Payment amount must be greater than 0');
|
||||
}
|
||||
|
||||
if (data.paymentDate > new Date()) {
|
||||
throw new ValidationError('Payment date cannot be in the future');
|
||||
}
|
||||
}
|
||||
}
|
||||
164
backend-api/src/services/InvoiceService.ts
Normal file
164
backend-api/src/services/InvoiceService.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {Invoice, InvoiceStatus, Prisma} from '@prisma/client';
|
||||
import {InvoiceRepository} from '../repositories/InvoiceRepository';
|
||||
import {NotFoundError, ValidationError} from '../utils/errors';
|
||||
|
||||
interface InvoiceLineItemDTO {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
interface CreateInvoiceDTO {
|
||||
clientId: string;
|
||||
invoiceNumber?: string;
|
||||
status?: InvoiceStatus;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
lineItems: InvoiceLineItemDTO[];
|
||||
tax?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface UpdateInvoiceDTO {
|
||||
status?: InvoiceStatus;
|
||||
dueDate?: Date;
|
||||
lineItems?: InvoiceLineItemDTO[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice Service
|
||||
* Handles invoice business logic including calculations
|
||||
*/
|
||||
export class InvoiceService {
|
||||
constructor(private invoiceRepository: InvoiceRepository) {}
|
||||
|
||||
async getAll(userId: string, filters?: {status?: InvoiceStatus}): Promise<Invoice[]> {
|
||||
return this.invoiceRepository.findAllByUser(userId, filters) as unknown as Invoice[];
|
||||
}
|
||||
|
||||
async getById(id: string, userId: string): Promise<Invoice> {
|
||||
const invoice = await this.invoiceRepository.findByIdAndUser(id, userId);
|
||||
if (!invoice) {
|
||||
throw new NotFoundError('Invoice not found');
|
||||
}
|
||||
return invoice as unknown as Invoice;
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateInvoiceDTO): Promise<Invoice> {
|
||||
this.validateInvoiceData(data);
|
||||
|
||||
// Generate invoice number if not provided
|
||||
const invoiceNumber =
|
||||
data.invoiceNumber || (await this.invoiceRepository.generateInvoiceNumber(userId));
|
||||
|
||||
// Check if invoice number already exists
|
||||
const exists = await this.invoiceRepository.invoiceNumberExists(userId, invoiceNumber);
|
||||
if (exists) {
|
||||
throw new ValidationError('Invoice number already exists');
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const lineItems = data.lineItems.map(item => ({
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
total: item.quantity * item.unitPrice,
|
||||
}));
|
||||
|
||||
const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0);
|
||||
const tax = data.tax || 0;
|
||||
const total = subtotal + tax;
|
||||
|
||||
return this.invoiceRepository.create({
|
||||
invoiceNumber,
|
||||
status: data.status || InvoiceStatus.DRAFT,
|
||||
issueDate: data.issueDate,
|
||||
dueDate: data.dueDate,
|
||||
subtotal,
|
||||
tax,
|
||||
total,
|
||||
notes: data.notes,
|
||||
user: {connect: {id: userId}},
|
||||
client: {connect: {id: data.clientId}},
|
||||
lineItems: {
|
||||
create: lineItems,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: UpdateInvoiceDTO): Promise<Invoice> {
|
||||
const invoice = await this.getById(id, userId);
|
||||
|
||||
const updateData: Prisma.InvoiceUpdateInput = {};
|
||||
|
||||
if (data.status) {
|
||||
updateData.status = data.status;
|
||||
}
|
||||
|
||||
if (data.dueDate) {
|
||||
updateData.dueDate = data.dueDate;
|
||||
}
|
||||
|
||||
if (data.notes !== undefined) {
|
||||
updateData.notes = data.notes;
|
||||
}
|
||||
|
||||
// Recalculate if line items are updated
|
||||
if (data.lineItems) {
|
||||
const lineItems = data.lineItems.map(item => ({
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
total: item.quantity * item.unitPrice,
|
||||
}));
|
||||
|
||||
const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0);
|
||||
const total = subtotal + (invoice.tax || 0);
|
||||
|
||||
updateData.subtotal = subtotal;
|
||||
updateData.total = total;
|
||||
updateData.lineItems = {
|
||||
deleteMany: {},
|
||||
create: lineItems,
|
||||
};
|
||||
}
|
||||
|
||||
return this.invoiceRepository.update(id, updateData);
|
||||
}
|
||||
|
||||
async updateStatus(id: string, userId: string, status: InvoiceStatus): Promise<Invoice> {
|
||||
return this.update(id, userId, {status});
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.getById(id, userId); // Verify ownership
|
||||
await this.invoiceRepository.delete(id);
|
||||
}
|
||||
|
||||
private validateInvoiceData(data: CreateInvoiceDTO): void {
|
||||
if (!data.clientId) {
|
||||
throw new ValidationError('Client ID is required');
|
||||
}
|
||||
|
||||
if (data.dueDate < data.issueDate) {
|
||||
throw new ValidationError('Due date cannot be before issue date');
|
||||
}
|
||||
|
||||
if (!data.lineItems || data.lineItems.length === 0) {
|
||||
throw new ValidationError('At least one line item is required');
|
||||
}
|
||||
|
||||
for (const item of data.lineItems) {
|
||||
if (!item.description || item.description.trim().length === 0) {
|
||||
throw new ValidationError('Line item description is required');
|
||||
}
|
||||
if (item.quantity <= 0) {
|
||||
throw new ValidationError('Line item quantity must be positive');
|
||||
}
|
||||
if (item.unitPrice < 0) {
|
||||
throw new ValidationError('Line item unit price cannot be negative');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
backend-api/src/services/LiabilityService.ts
Normal file
135
backend-api/src/services/LiabilityService.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {Liability} from '@prisma/client';
|
||||
import {LiabilityRepository} from '../repositories/LiabilityRepository';
|
||||
import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors';
|
||||
|
||||
export interface CreateLiabilityDTO {
|
||||
name: string;
|
||||
type: string;
|
||||
currentBalance: number;
|
||||
interestRate?: number;
|
||||
minimumPayment?: number;
|
||||
dueDate?: Date;
|
||||
creditor?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLiabilityDTO {
|
||||
name?: string;
|
||||
type?: string;
|
||||
currentBalance?: number;
|
||||
interestRate?: number;
|
||||
minimumPayment?: number;
|
||||
dueDate?: Date;
|
||||
creditor?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for Liability business logic
|
||||
* Implements Single Responsibility Principle - handles only business logic
|
||||
* Implements Dependency Inversion - depends on repository abstraction
|
||||
*/
|
||||
export class LiabilityService {
|
||||
constructor(private liabilityRepository: LiabilityRepository) {}
|
||||
|
||||
/**
|
||||
* Create a new liability
|
||||
*/
|
||||
async create(userId: string, data: CreateLiabilityDTO): Promise<Liability> {
|
||||
this.validateLiabilityData(data);
|
||||
|
||||
return this.liabilityRepository.create({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
currentBalance: data.currentBalance,
|
||||
interestRate: data.interestRate,
|
||||
minimumPayment: data.minimumPayment,
|
||||
dueDate: data.dueDate,
|
||||
creditor: data.creditor,
|
||||
notes: data.notes,
|
||||
user: {
|
||||
connect: {id: userId},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all liabilities for a user
|
||||
*/
|
||||
async getAllByUser(userId: string): Promise<Liability[]> {
|
||||
return this.liabilityRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single liability by ID
|
||||
*/
|
||||
async getById(id: string, userId: string): Promise<Liability> {
|
||||
const liability = await this.liabilityRepository.findById(id);
|
||||
|
||||
if (!liability) {
|
||||
throw new NotFoundError('Liability not found');
|
||||
}
|
||||
|
||||
// Ensure user owns this liability
|
||||
if (liability.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return liability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a liability
|
||||
*/
|
||||
async update(id: string, userId: string, data: UpdateLiabilityDTO): Promise<Liability> {
|
||||
// Verify ownership
|
||||
await this.getById(id, userId);
|
||||
|
||||
if (data.currentBalance !== undefined || data.interestRate !== undefined || data.minimumPayment !== undefined) {
|
||||
this.validateLiabilityData(data as CreateLiabilityDTO);
|
||||
}
|
||||
|
||||
return this.liabilityRepository.update(id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a liability
|
||||
*/
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
// Verify ownership
|
||||
await this.getById(id, userId);
|
||||
|
||||
await this.liabilityRepository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total liability value for a user
|
||||
*/
|
||||
async getTotalValue(userId: string): Promise<number> {
|
||||
return this.liabilityRepository.getTotalValue(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get liabilities grouped by type
|
||||
*/
|
||||
async getByType(userId: string): Promise<Record<string, Liability[]>> {
|
||||
return this.liabilityRepository.getByType(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate liability data
|
||||
*/
|
||||
private validateLiabilityData(data: CreateLiabilityDTO | UpdateLiabilityDTO): void {
|
||||
if (data.currentBalance !== undefined && data.currentBalance < 0) {
|
||||
throw new ValidationError('Current balance cannot be negative');
|
||||
}
|
||||
|
||||
if (data.interestRate !== undefined && (data.interestRate < 0 || data.interestRate > 100)) {
|
||||
throw new ValidationError('Interest rate must be between 0 and 100');
|
||||
}
|
||||
|
||||
if (data.minimumPayment !== undefined && data.minimumPayment < 0) {
|
||||
throw new ValidationError('Minimum payment cannot be negative');
|
||||
}
|
||||
}
|
||||
}
|
||||
184
backend-api/src/services/NetWorthService.ts
Normal file
184
backend-api/src/services/NetWorthService.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import {NetWorthSnapshot} from '@prisma/client';
|
||||
import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository';
|
||||
import {AssetRepository} from '../repositories/AssetRepository';
|
||||
import {LiabilityRepository} from '../repositories/LiabilityRepository';
|
||||
import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors';
|
||||
|
||||
export interface CreateSnapshotDTO {
|
||||
date: Date;
|
||||
totalAssets: number;
|
||||
totalLiabilities: number;
|
||||
netWorth: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for Net Worth business logic
|
||||
* Implements Single Responsibility Principle - handles only business logic
|
||||
* Implements Dependency Inversion - depends on repository abstractions
|
||||
*/
|
||||
export class NetWorthService {
|
||||
constructor(
|
||||
private snapshotRepository: NetWorthSnapshotRepository,
|
||||
private assetRepository: AssetRepository,
|
||||
private liabilityRepository: LiabilityRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new net worth snapshot
|
||||
*/
|
||||
async createSnapshot(userId: string, data: CreateSnapshotDTO): Promise<NetWorthSnapshot> {
|
||||
this.validateSnapshotData(data);
|
||||
|
||||
// Check if snapshot already exists for this date
|
||||
const exists = await this.snapshotRepository.existsForDate(userId, data.date);
|
||||
if (exists) {
|
||||
throw new ValidationError('A snapshot already exists for this date');
|
||||
}
|
||||
|
||||
// Verify the net worth calculation
|
||||
const calculatedNetWorth = data.totalAssets - data.totalLiabilities;
|
||||
if (Math.abs(calculatedNetWorth - data.netWorth) > 0.01) {
|
||||
// Allow small floating point differences
|
||||
throw new ValidationError(
|
||||
`Net worth calculation mismatch. Expected ${calculatedNetWorth}, got ${data.netWorth}`
|
||||
);
|
||||
}
|
||||
|
||||
return this.snapshotRepository.create({
|
||||
date: data.date,
|
||||
totalAssets: data.totalAssets,
|
||||
totalLiabilities: data.totalLiabilities,
|
||||
netWorth: data.netWorth,
|
||||
notes: data.notes,
|
||||
user: {
|
||||
connect: {id: userId},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot from current assets and liabilities
|
||||
*/
|
||||
async createFromCurrent(userId: string, notes?: string): Promise<NetWorthSnapshot> {
|
||||
const totalAssets = await this.assetRepository.getTotalValue(userId);
|
||||
const totalLiabilities = await this.liabilityRepository.getTotalValue(userId);
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
return this.createSnapshot(userId, {
|
||||
date: new Date(),
|
||||
totalAssets,
|
||||
totalLiabilities,
|
||||
netWorth,
|
||||
notes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all snapshots for a user
|
||||
*/
|
||||
async getAllSnapshots(userId: string): Promise<NetWorthSnapshot[]> {
|
||||
return this.snapshotRepository.findAllByUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshots within a date range
|
||||
*/
|
||||
async getSnapshotsByDateRange(
|
||||
userId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<NetWorthSnapshot[]> {
|
||||
return this.snapshotRepository.getByDateRange(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current net worth (from latest snapshot or calculate from current data)
|
||||
*/
|
||||
async getCurrentNetWorth(userId: string): Promise<{
|
||||
totalAssets: number;
|
||||
totalLiabilities: number;
|
||||
netWorth: number;
|
||||
asOf: Date;
|
||||
isCalculated: boolean;
|
||||
}> {
|
||||
const latestSnapshot = await this.snapshotRepository.getLatest(userId);
|
||||
|
||||
// If we have a recent snapshot (within last 24 hours), use it
|
||||
if (latestSnapshot) {
|
||||
const hoursSinceSnapshot =
|
||||
(Date.now() - latestSnapshot.date.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursSinceSnapshot < 24) {
|
||||
return {
|
||||
totalAssets: latestSnapshot.totalAssets,
|
||||
totalLiabilities: latestSnapshot.totalLiabilities,
|
||||
netWorth: latestSnapshot.netWorth,
|
||||
asOf: latestSnapshot.date,
|
||||
isCalculated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, calculate from current assets and liabilities
|
||||
const totalAssets = await this.assetRepository.getTotalValue(userId);
|
||||
const totalLiabilities = await this.liabilityRepository.getTotalValue(userId);
|
||||
|
||||
return {
|
||||
totalAssets,
|
||||
totalLiabilities,
|
||||
netWorth: totalAssets - totalLiabilities,
|
||||
asOf: new Date(),
|
||||
isCalculated: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single snapshot by ID
|
||||
*/
|
||||
async getById(id: string, userId: string): Promise<NetWorthSnapshot> {
|
||||
const snapshot = await this.snapshotRepository.findById(id);
|
||||
|
||||
if (!snapshot) {
|
||||
throw new NotFoundError('Snapshot not found');
|
||||
}
|
||||
|
||||
if (snapshot.userId !== userId) {
|
||||
throw new ForbiddenError('Access denied');
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a snapshot
|
||||
*/
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.getById(id, userId);
|
||||
await this.snapshotRepository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get growth statistics
|
||||
*/
|
||||
async getGrowthStats(userId: string, limit?: number): Promise<any[]> {
|
||||
return this.snapshotRepository.getGrowthStats(userId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate snapshot data
|
||||
*/
|
||||
private validateSnapshotData(data: CreateSnapshotDTO): void {
|
||||
if (data.totalAssets < 0) {
|
||||
throw new ValidationError('Total assets cannot be negative');
|
||||
}
|
||||
|
||||
if (data.totalLiabilities < 0) {
|
||||
throw new ValidationError('Total liabilities cannot be negative');
|
||||
}
|
||||
|
||||
if (data.date > new Date()) {
|
||||
throw new ValidationError('Snapshot date cannot be in the future');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user