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:
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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user