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:
2025-12-07 12:59:09 -05:00
parent 9d493ba82f
commit cd93dcbfd2
70 changed files with 8649 additions and 6 deletions

View 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)');
}
}
}
}