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:
29
backend-api/src/config/database.ts
Normal file
29
backend-api/src/config/database.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {PrismaClient} from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Database connection singleton
|
||||
* Implements Single Responsibility: Only manages database connection
|
||||
*/
|
||||
class DatabaseConnection {
|
||||
private static instance: PrismaClient;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): PrismaClient {
|
||||
if (!DatabaseConnection.instance) {
|
||||
DatabaseConnection.instance = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
}
|
||||
return DatabaseConnection.instance;
|
||||
}
|
||||
|
||||
public static async disconnect(): Promise<void> {
|
||||
if (DatabaseConnection.instance) {
|
||||
await DatabaseConnection.instance.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const prisma = DatabaseConnection.getInstance();
|
||||
export {DatabaseConnection};
|
||||
30
backend-api/src/config/env.ts
Normal file
30
backend-api/src/config/env.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
/**
|
||||
* Environment configuration schema
|
||||
* Validates environment variables at startup
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().transform(Number).default('3000'),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
JWT_SECRET: z.string().min(32),
|
||||
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5174'),
|
||||
});
|
||||
|
||||
type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
function validateEnv(): EnvConfig {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:');
|
||||
console.error(result.error.format());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
62
backend-api/src/controllers/AssetController.ts
Normal file
62
backend-api/src/controllers/AssetController.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {z} from 'zod';
|
||||
import {AssetService} from '../services/AssetService';
|
||||
import {AssetRepository} from '../repositories/AssetRepository';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {AssetType} from '@prisma/client';
|
||||
|
||||
const createAssetSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.nativeEnum(AssetType),
|
||||
value: z.number().min(0),
|
||||
});
|
||||
|
||||
const updateAssetSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
type: z.nativeEnum(AssetType).optional(),
|
||||
value: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Asset Controller
|
||||
* Handles asset-related HTTP requests
|
||||
*/
|
||||
export class AssetController {
|
||||
private assetService: AssetService;
|
||||
|
||||
constructor() {
|
||||
this.assetService = new AssetService(new AssetRepository());
|
||||
}
|
||||
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const assets = await this.assetService.getAll(userId);
|
||||
return reply.send({assets});
|
||||
}
|
||||
|
||||
async getById(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const asset = await this.assetService.getById(request.params.id, userId);
|
||||
return reply.send({asset});
|
||||
}
|
||||
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createAssetSchema.parse(request.body);
|
||||
const asset = await this.assetService.create(userId, data);
|
||||
return reply.status(201).send({asset});
|
||||
}
|
||||
|
||||
async update(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = updateAssetSchema.parse(request.body);
|
||||
const asset = await this.assetService.update(request.params.id, userId, data);
|
||||
return reply.send({asset});
|
||||
}
|
||||
|
||||
async delete(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
await this.assetService.delete(request.params.id, userId);
|
||||
return reply.status(204).send();
|
||||
}
|
||||
}
|
||||
72
backend-api/src/controllers/AuthController.ts
Normal file
72
backend-api/src/controllers/AuthController.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {z} from 'zod';
|
||||
import {AuthService} from '../services/AuthService';
|
||||
import {UserRepository} from '../repositories/UserRepository';
|
||||
import {DebtCategoryService} from '../services/DebtCategoryService';
|
||||
import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository';
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth Controller
|
||||
* Implements Single Responsibility: Handles authentication HTTP requests
|
||||
* Implements Dependency Inversion: Depends on AuthService
|
||||
*/
|
||||
export class AuthController {
|
||||
private authService: AuthService;
|
||||
|
||||
constructor() {
|
||||
const userRepository = new UserRepository();
|
||||
const debtCategoryRepository = new DebtCategoryRepository();
|
||||
const debtCategoryService = new DebtCategoryService(debtCategoryRepository);
|
||||
this.authService = new AuthService(userRepository, debtCategoryService);
|
||||
}
|
||||
|
||||
async register(request: FastifyRequest, reply: FastifyReply) {
|
||||
const data = registerSchema.parse(request.body);
|
||||
const user = await this.authService.register(data.email, data.password, data.name);
|
||||
|
||||
const token = request.server.jwt.sign({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
return reply.status(201).send({
|
||||
user,
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
async login(request: FastifyRequest, reply: FastifyReply) {
|
||||
const data = loginSchema.parse(request.body);
|
||||
const user = await this.authService.login(data.email, data.password);
|
||||
|
||||
const token = request.server.jwt.sign({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
const {password: _, ...userWithoutPassword} = user;
|
||||
|
||||
return reply.send({
|
||||
user: userWithoutPassword,
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user!.id;
|
||||
const user = await this.authService.getUserById(userId);
|
||||
|
||||
return reply.send({user});
|
||||
}
|
||||
}
|
||||
192
backend-api/src/controllers/CashflowController.ts
Normal file
192
backend-api/src/controllers/CashflowController.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {CashflowService} from '../services/CashflowService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const createIncomeSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
amount: z.number().min(0.01),
|
||||
frequency: z.string(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateIncomeSchema = createIncomeSchema.partial();
|
||||
|
||||
const createExpenseSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
amount: z.number().min(0.01),
|
||||
category: z.string(),
|
||||
frequency: z.string(),
|
||||
dueDate: z.string().transform(str => new Date(str)).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateExpenseSchema = createExpenseSchema.partial();
|
||||
|
||||
const createTransactionSchema = z.object({
|
||||
type: z.string(),
|
||||
category: z.string(),
|
||||
amount: z.number().min(0.01),
|
||||
date: z.string().transform(str => new Date(str)),
|
||||
description: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for Cashflow endpoints
|
||||
*/
|
||||
export class CashflowController {
|
||||
constructor(private cashflowService: CashflowService) {}
|
||||
|
||||
// Income Source endpoints
|
||||
async createIncome(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createIncomeSchema.parse(request.body);
|
||||
const income = await this.cashflowService.createIncome(userId, data);
|
||||
return reply.status(201).send({income});
|
||||
}
|
||||
|
||||
async getAllIncome(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const income = await this.cashflowService.getAllIncome(userId);
|
||||
return reply.send({income});
|
||||
}
|
||||
|
||||
async getOneIncome(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const income = await this.cashflowService.getIncomeById(id, userId);
|
||||
return reply.send({income});
|
||||
}
|
||||
|
||||
async updateIncome(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const data = updateIncomeSchema.parse(request.body);
|
||||
const income = await this.cashflowService.updateIncome(id, userId, data);
|
||||
return reply.send({income});
|
||||
}
|
||||
|
||||
async deleteIncome(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
await this.cashflowService.deleteIncome(id, userId);
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
async getTotalMonthlyIncome(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const total = await this.cashflowService.getTotalMonthlyIncome(userId);
|
||||
return reply.send({total});
|
||||
}
|
||||
|
||||
// Expense endpoints
|
||||
async createExpense(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createExpenseSchema.parse(request.body);
|
||||
const expense = await this.cashflowService.createExpense(userId, data);
|
||||
return reply.status(201).send({expense});
|
||||
}
|
||||
|
||||
async getAllExpenses(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {byCategory} = request.query as {byCategory?: string};
|
||||
|
||||
if (byCategory === 'true') {
|
||||
const expenses = await this.cashflowService.getExpensesByCategory(userId);
|
||||
return reply.send({expenses});
|
||||
}
|
||||
|
||||
const expenses = await this.cashflowService.getAllExpenses(userId);
|
||||
return reply.send({expenses});
|
||||
}
|
||||
|
||||
async getOneExpense(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const expense = await this.cashflowService.getExpenseById(id, userId);
|
||||
return reply.send({expense});
|
||||
}
|
||||
|
||||
async updateExpense(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const data = updateExpenseSchema.parse(request.body);
|
||||
const expense = await this.cashflowService.updateExpense(id, userId, data);
|
||||
return reply.send({expense});
|
||||
}
|
||||
|
||||
async deleteExpense(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
await this.cashflowService.deleteExpense(id, userId);
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
async getTotalMonthlyExpenses(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const total = await this.cashflowService.getTotalMonthlyExpenses(userId);
|
||||
return reply.send({total});
|
||||
}
|
||||
|
||||
// Transaction endpoints
|
||||
async createTransaction(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createTransactionSchema.parse(request.body);
|
||||
const transaction = await this.cashflowService.createTransaction(userId, data);
|
||||
return reply.status(201).send({transaction});
|
||||
}
|
||||
|
||||
async getAllTransactions(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {type, startDate, endDate} = request.query as {
|
||||
type?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
if (type) {
|
||||
const transactions = await this.cashflowService.getTransactionsByType(userId, type);
|
||||
return reply.send({transactions});
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
const transactions = await this.cashflowService.getTransactionsByDateRange(
|
||||
userId,
|
||||
new Date(startDate),
|
||||
new Date(endDate)
|
||||
);
|
||||
return reply.send({transactions});
|
||||
}
|
||||
|
||||
const transactions = await this.cashflowService.getAllTransactions(userId);
|
||||
return reply.send({transactions});
|
||||
}
|
||||
|
||||
async getOneTransaction(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const transaction = await this.cashflowService.getTransactionById(id, userId);
|
||||
return reply.send({transaction});
|
||||
}
|
||||
|
||||
async deleteTransaction(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
await this.cashflowService.deleteTransaction(id, userId);
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
async getCashflowSummary(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {startDate, endDate} = request.query as {startDate: string; endDate: string};
|
||||
|
||||
const summary = await this.cashflowService.getCashflowSummary(
|
||||
userId,
|
||||
new Date(startDate),
|
||||
new Date(endDate)
|
||||
);
|
||||
|
||||
return reply.send(summary);
|
||||
}
|
||||
}
|
||||
103
backend-api/src/controllers/ClientController.ts
Normal file
103
backend-api/src/controllers/ClientController.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {ClientService} from '../services/ClientService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateClientSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for Client endpoints
|
||||
* Implements Single Responsibility Principle - handles only HTTP layer
|
||||
*/
|
||||
export class ClientController {
|
||||
constructor(private clientService: ClientService) {}
|
||||
|
||||
/**
|
||||
* Create a new client
|
||||
*/
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createClientSchema.parse(request.body);
|
||||
|
||||
const client = await this.clientService.create(userId, data);
|
||||
|
||||
return reply.status(201).send({client});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clients for the authenticated user
|
||||
*/
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {withStats} = request.query as {withStats?: string};
|
||||
|
||||
if (withStats === 'true') {
|
||||
const clients = await this.clientService.getWithStats(userId);
|
||||
return reply.send({clients});
|
||||
}
|
||||
|
||||
const clients = await this.clientService.getAllByUser(userId);
|
||||
return reply.send({clients});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single client by ID
|
||||
*/
|
||||
async getOne(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
const client = await this.clientService.getById(id, userId);
|
||||
|
||||
return reply.send({client});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a client
|
||||
*/
|
||||
async update(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const data = updateClientSchema.parse(request.body);
|
||||
|
||||
const client = await this.clientService.update(id, userId, data);
|
||||
|
||||
return reply.send({client});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a client
|
||||
*/
|
||||
async delete(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
await this.clientService.delete(id, userId);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total revenue from all clients
|
||||
*/
|
||||
async getTotalRevenue(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const totalRevenue = await this.clientService.getTotalRevenue(userId);
|
||||
|
||||
return reply.send({totalRevenue});
|
||||
}
|
||||
}
|
||||
17
backend-api/src/controllers/DashboardController.ts
Normal file
17
backend-api/src/controllers/DashboardController.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {DashboardService} from '../services/DashboardService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
|
||||
/**
|
||||
* Controller for Dashboard endpoints
|
||||
*/
|
||||
export class DashboardController {
|
||||
constructor(private dashboardService: DashboardService) {}
|
||||
|
||||
async getSummary(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const summary = await this.dashboardService.getSummary(userId);
|
||||
|
||||
return reply.send(summary);
|
||||
}
|
||||
}
|
||||
116
backend-api/src/controllers/DebtAccountController.ts
Normal file
116
backend-api/src/controllers/DebtAccountController.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {DebtAccountService} from '../services/DebtAccountService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const createAccountSchema = z.object({
|
||||
categoryId: z.string().uuid(),
|
||||
name: z.string().min(1).max(255),
|
||||
creditor: z.string().min(1).max(255),
|
||||
accountNumber: z.string().max(100).optional(),
|
||||
originalBalance: z.number().min(0),
|
||||
currentBalance: z.number().min(0),
|
||||
interestRate: z.number().min(0).max(100).optional(),
|
||||
minimumPayment: z.number().min(0).optional(),
|
||||
dueDate: z.string().transform(str => new Date(str)).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateAccountSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
creditor: z.string().min(1).max(255).optional(),
|
||||
accountNumber: z.string().max(100).optional(),
|
||||
currentBalance: z.number().min(0).optional(),
|
||||
interestRate: z.number().min(0).max(100).optional(),
|
||||
minimumPayment: z.number().min(0).optional(),
|
||||
dueDate: z.string().transform(str => new Date(str)).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for DebtAccount endpoints
|
||||
* Implements Single Responsibility Principle - handles only HTTP layer
|
||||
*/
|
||||
export class DebtAccountController {
|
||||
constructor(private accountService: DebtAccountService) {}
|
||||
|
||||
/**
|
||||
* Create a new debt account
|
||||
*/
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createAccountSchema.parse(request.body);
|
||||
|
||||
const account = await this.accountService.create(userId, data);
|
||||
|
||||
return reply.status(201).send({account});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all debt accounts
|
||||
*/
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {withStats, categoryId} = request.query as {withStats?: string; categoryId?: string};
|
||||
|
||||
if (categoryId) {
|
||||
const accounts = await this.accountService.getByCategory(categoryId, userId);
|
||||
return reply.send({accounts});
|
||||
}
|
||||
|
||||
if (withStats === 'true') {
|
||||
const accounts = await this.accountService.getWithStats(userId);
|
||||
return reply.send({accounts});
|
||||
}
|
||||
|
||||
const accounts = await this.accountService.getAllByUser(userId);
|
||||
return reply.send({accounts});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single debt account
|
||||
*/
|
||||
async getOne(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
const account = await this.accountService.getById(id, userId);
|
||||
|
||||
return reply.send({account});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a debt account
|
||||
*/
|
||||
async update(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const data = updateAccountSchema.parse(request.body);
|
||||
|
||||
const account = await this.accountService.update(id, userId, data);
|
||||
|
||||
return reply.send({account});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a debt account
|
||||
*/
|
||||
async delete(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
await this.accountService.delete(id, userId);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total debt
|
||||
*/
|
||||
async getTotalDebt(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const totalDebt = await this.accountService.getTotalDebt(userId);
|
||||
|
||||
return reply.send({totalDebt});
|
||||
}
|
||||
}
|
||||
89
backend-api/src/controllers/DebtCategoryController.ts
Normal file
89
backend-api/src/controllers/DebtCategoryController.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {DebtCategoryService} from '../services/DebtCategoryService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const createCategorySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(),
|
||||
});
|
||||
|
||||
const updateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for DebtCategory endpoints
|
||||
* Implements Single Responsibility Principle - handles only HTTP layer
|
||||
*/
|
||||
export class DebtCategoryController {
|
||||
constructor(private categoryService: DebtCategoryService) {}
|
||||
|
||||
/**
|
||||
* Create a new debt category
|
||||
*/
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createCategorySchema.parse(request.body);
|
||||
|
||||
const category = await this.categoryService.create(userId, data);
|
||||
|
||||
return reply.status(201).send({category});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all debt categories
|
||||
*/
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {withStats} = request.query as {withStats?: string};
|
||||
|
||||
if (withStats === 'true') {
|
||||
const categories = await this.categoryService.getWithStats(userId);
|
||||
return reply.send({categories});
|
||||
}
|
||||
|
||||
const categories = await this.categoryService.getAllByUser(userId);
|
||||
return reply.send({categories});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single debt category
|
||||
*/
|
||||
async getOne(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
const category = await this.categoryService.getById(id, userId);
|
||||
|
||||
return reply.send({category});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a debt category
|
||||
*/
|
||||
async update(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const data = updateCategorySchema.parse(request.body);
|
||||
|
||||
const category = await this.categoryService.update(id, userId, data);
|
||||
|
||||
return reply.send({category});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a debt category
|
||||
*/
|
||||
async delete(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
await this.categoryService.delete(id, userId);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
}
|
||||
94
backend-api/src/controllers/DebtPaymentController.ts
Normal file
94
backend-api/src/controllers/DebtPaymentController.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {DebtPaymentService} from '../services/DebtPaymentService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const createPaymentSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
amount: z.number().min(0.01),
|
||||
paymentDate: z.string().transform(str => new Date(str)),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for DebtPayment endpoints
|
||||
* Implements Single Responsibility Principle - handles only HTTP layer
|
||||
*/
|
||||
export class DebtPaymentController {
|
||||
constructor(private paymentService: DebtPaymentService) {}
|
||||
|
||||
/**
|
||||
* Create a new debt payment
|
||||
*/
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createPaymentSchema.parse(request.body);
|
||||
|
||||
const payment = await this.paymentService.create(userId, data);
|
||||
|
||||
return reply.status(201).send({payment});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all debt payments
|
||||
*/
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {accountId, startDate, endDate} = request.query as {
|
||||
accountId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
if (accountId) {
|
||||
const payments = await this.paymentService.getByAccount(accountId, userId);
|
||||
return reply.send({payments});
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
const payments = await this.paymentService.getByDateRange(
|
||||
userId,
|
||||
new Date(startDate),
|
||||
new Date(endDate)
|
||||
);
|
||||
return reply.send({payments});
|
||||
}
|
||||
|
||||
const payments = await this.paymentService.getAllByUser(userId);
|
||||
return reply.send({payments});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single debt payment
|
||||
*/
|
||||
async getOne(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
const payment = await this.paymentService.getById(id, userId);
|
||||
|
||||
return reply.send({payment});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a debt payment
|
||||
*/
|
||||
async delete(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
await this.paymentService.delete(id, userId);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total payments
|
||||
*/
|
||||
async getTotalPayments(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const totalPayments = await this.paymentService.getTotalPayments(userId);
|
||||
|
||||
return reply.send({totalPayments});
|
||||
}
|
||||
}
|
||||
137
backend-api/src/controllers/InvoiceController.ts
Normal file
137
backend-api/src/controllers/InvoiceController.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {InvoiceService} from '../services/InvoiceService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
description: z.string().min(1),
|
||||
quantity: z.number().min(1),
|
||||
unitPrice: z.number().min(0),
|
||||
amount: z.number().min(0),
|
||||
});
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
issueDate: z.string().transform(str => new Date(str)),
|
||||
dueDate: z.string().transform(str => new Date(str)),
|
||||
lineItems: z.array(lineItemSchema).min(1),
|
||||
notes: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z.object({
|
||||
issueDate: z.string().transform(str => new Date(str)).optional(),
|
||||
dueDate: z.string().transform(str => new Date(str)).optional(),
|
||||
lineItems: z.array(lineItemSchema).min(1).optional(),
|
||||
notes: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateStatusSchema = z.object({
|
||||
status: z.enum(['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED']),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for Invoice endpoints
|
||||
* Implements Single Responsibility Principle - handles only HTTP layer
|
||||
*/
|
||||
export class InvoiceController {
|
||||
constructor(private invoiceService: InvoiceService) {}
|
||||
|
||||
/**
|
||||
* Create a new invoice
|
||||
*/
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createInvoiceSchema.parse(request.body);
|
||||
|
||||
const invoice = await this.invoiceService.create(userId, data);
|
||||
|
||||
return reply.status(201).send({invoice});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all invoices for the authenticated user
|
||||
*/
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {clientId, status} = request.query as {clientId?: string; status?: string};
|
||||
|
||||
const invoices = await this.invoiceService.getAllByUser(userId, {
|
||||
clientId,
|
||||
status,
|
||||
});
|
||||
|
||||
return reply.send({invoices});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single invoice by ID
|
||||
*/
|
||||
async getOne(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
const invoice = await this.invoiceService.getById(id, userId);
|
||||
|
||||
return reply.send({invoice});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an invoice
|
||||
*/
|
||||
async update(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const data = updateInvoiceSchema.parse(request.body);
|
||||
|
||||
const invoice = await this.invoiceService.update(id, userId, data);
|
||||
|
||||
return reply.send({invoice});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice status
|
||||
*/
|
||||
async updateStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const {status} = updateStatusSchema.parse(request.body);
|
||||
|
||||
const invoice = await this.invoiceService.updateStatus(id, userId, status);
|
||||
|
||||
return reply.send({invoice});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an invoice
|
||||
*/
|
||||
async delete(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
await this.invoiceService.delete(id, userId);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice statistics
|
||||
*/
|
||||
async getStats(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const stats = await this.invoiceService.getStats(userId);
|
||||
|
||||
return reply.send({stats});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overdue invoices
|
||||
*/
|
||||
async getOverdue(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const overdueInvoices = await this.invoiceService.getOverdueInvoices(userId);
|
||||
|
||||
return reply.send({invoices: overdueInvoices});
|
||||
}
|
||||
}
|
||||
113
backend-api/src/controllers/LiabilityController.ts
Normal file
113
backend-api/src/controllers/LiabilityController.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {LiabilityService} from '../services/LiabilityService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const createLiabilitySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
type: z.string().min(1),
|
||||
currentBalance: z.number().min(0),
|
||||
interestRate: z.number().min(0).max(100).optional(),
|
||||
minimumPayment: z.number().min(0).optional(),
|
||||
dueDate: z.string().transform(str => new Date(str)).optional(),
|
||||
creditor: z.string().max(255).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateLiabilitySchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
type: z.string().min(1).optional(),
|
||||
currentBalance: z.number().min(0).optional(),
|
||||
interestRate: z.number().min(0).max(100).optional(),
|
||||
minimumPayment: z.number().min(0).optional(),
|
||||
dueDate: z.string().transform(str => new Date(str)).optional(),
|
||||
creditor: z.string().max(255).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for Liability endpoints
|
||||
* Implements Single Responsibility Principle - handles only HTTP layer
|
||||
*/
|
||||
export class LiabilityController {
|
||||
constructor(private liabilityService: LiabilityService) {}
|
||||
|
||||
/**
|
||||
* Create a new liability
|
||||
*/
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createLiabilitySchema.parse(request.body);
|
||||
|
||||
const liability = await this.liabilityService.create(userId, data);
|
||||
|
||||
return reply.status(201).send({liability});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all liabilities for the authenticated user
|
||||
*/
|
||||
async getAll(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const liabilities = await this.liabilityService.getAllByUser(userId);
|
||||
|
||||
return reply.send({liabilities});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single liability by ID
|
||||
*/
|
||||
async getOne(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
const liability = await this.liabilityService.getById(id, userId);
|
||||
|
||||
return reply.send({liability});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a liability
|
||||
*/
|
||||
async update(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
const data = updateLiabilitySchema.parse(request.body);
|
||||
|
||||
const liability = await this.liabilityService.update(id, userId, data);
|
||||
|
||||
return reply.send({liability});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a liability
|
||||
*/
|
||||
async delete(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
await this.liabilityService.delete(id, userId);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total liability value
|
||||
*/
|
||||
async getTotalValue(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const totalValue = await this.liabilityService.getTotalValue(userId);
|
||||
|
||||
return reply.send({totalValue});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get liabilities grouped by type
|
||||
*/
|
||||
async getByType(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const liabilitiesByType = await this.liabilityService.getByType(userId);
|
||||
|
||||
return reply.send({liabilitiesByType});
|
||||
}
|
||||
}
|
||||
129
backend-api/src/controllers/NetWorthController.ts
Normal file
129
backend-api/src/controllers/NetWorthController.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {NetWorthService} from '../services/NetWorthService';
|
||||
import {getUserId} from '../middleware/auth';
|
||||
import {z} from 'zod';
|
||||
|
||||
const createSnapshotSchema = z.object({
|
||||
date: z.string().transform(str => new Date(str)),
|
||||
totalAssets: z.number().min(0),
|
||||
totalLiabilities: z.number().min(0),
|
||||
netWorth: z.number(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const createFromCurrentSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const dateRangeSchema = z.object({
|
||||
startDate: z.string().transform(str => new Date(str)),
|
||||
endDate: z.string().transform(str => new Date(str)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controller for Net Worth endpoints
|
||||
* Implements Single Responsibility Principle - handles only HTTP layer
|
||||
*/
|
||||
export class NetWorthController {
|
||||
constructor(private netWorthService: NetWorthService) {}
|
||||
|
||||
/**
|
||||
* Get current net worth
|
||||
*/
|
||||
async getCurrent(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const current = await this.netWorthService.getCurrentNetWorth(userId);
|
||||
|
||||
return reply.send(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all snapshots
|
||||
*/
|
||||
async getAllSnapshots(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const snapshots = await this.netWorthService.getAllSnapshots(userId);
|
||||
|
||||
return reply.send({snapshots});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshots by date range
|
||||
*/
|
||||
async getByDateRange(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {startDate, endDate} = request.query as {startDate: string; endDate: string};
|
||||
|
||||
const parsed = dateRangeSchema.parse({startDate, endDate});
|
||||
const snapshots = await this.netWorthService.getSnapshotsByDateRange(
|
||||
userId,
|
||||
parsed.startDate,
|
||||
parsed.endDate
|
||||
);
|
||||
|
||||
return reply.send({snapshots});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a manual snapshot
|
||||
*/
|
||||
async createSnapshot(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const data = createSnapshotSchema.parse(request.body);
|
||||
|
||||
const snapshot = await this.netWorthService.createSnapshot(userId, data);
|
||||
|
||||
return reply.status(201).send({snapshot});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create snapshot from current assets and liabilities
|
||||
*/
|
||||
async createFromCurrent(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {notes} = createFromCurrentSchema.parse(request.body);
|
||||
|
||||
const snapshot = await this.netWorthService.createFromCurrent(userId, notes);
|
||||
|
||||
return reply.status(201).send({snapshot});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single snapshot
|
||||
*/
|
||||
async getOne(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
const snapshot = await this.netWorthService.getById(id, userId);
|
||||
|
||||
return reply.send({snapshot});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a snapshot
|
||||
*/
|
||||
async delete(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {id} = request.params as {id: string};
|
||||
|
||||
await this.netWorthService.delete(id, userId);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get growth statistics
|
||||
*/
|
||||
async getGrowthStats(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = getUserId(request);
|
||||
const {limit} = request.query as {limit?: string};
|
||||
|
||||
const stats = await this.netWorthService.getGrowthStats(
|
||||
userId,
|
||||
limit ? parseInt(limit) : undefined
|
||||
);
|
||||
|
||||
return reply.send({stats});
|
||||
}
|
||||
}
|
||||
37
backend-api/src/index.ts
Normal file
37
backend-api/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {buildServer} from './server';
|
||||
import {env} from './config/env';
|
||||
import {DatabaseConnection} from './config/database';
|
||||
|
||||
/**
|
||||
* Application entry point
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
const server = await buildServer();
|
||||
|
||||
// Start server
|
||||
await server.listen({
|
||||
port: env.PORT,
|
||||
host: '0.0.0.0',
|
||||
});
|
||||
|
||||
server.log.info(`🚀 Server listening on http://localhost:${env.PORT}`);
|
||||
server.log.info(`📚 API Documentation available at http://localhost:${env.PORT}/docs`);
|
||||
|
||||
// Graceful shutdown
|
||||
const signals = ['SIGINT', 'SIGTERM'];
|
||||
signals.forEach(signal => {
|
||||
process.on(signal, async () => {
|
||||
server.log.info(`${signal} received, shutting down gracefully...`);
|
||||
await server.close();
|
||||
await DatabaseConnection.disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
36
backend-api/src/middleware/auth.ts
Normal file
36
backend-api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {FastifyRequest, FastifyReply} from 'fastify';
|
||||
import {UnauthorizedError} from '../utils/errors';
|
||||
|
||||
/**
|
||||
* Extend Fastify Request with user property
|
||||
*/
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication Middleware
|
||||
* Verifies JWT token and attaches user to request
|
||||
*/
|
||||
export async function authenticate(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user ID from authenticated request
|
||||
*/
|
||||
export function getUserId(request: FastifyRequest): string {
|
||||
if (!request.user || !request.user.id) {
|
||||
throw new UnauthorizedError('User not authenticated');
|
||||
}
|
||||
return request.user.id;
|
||||
}
|
||||
64
backend-api/src/middleware/errorHandler.ts
Normal file
64
backend-api/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {FastifyError, FastifyReply, FastifyRequest} from 'fastify';
|
||||
import {AppError} from '../utils/errors';
|
||||
import {ZodError} from 'zod';
|
||||
|
||||
/**
|
||||
* Global Error Handler
|
||||
* Implements Single Responsibility: Handles all error responses
|
||||
*/
|
||||
export async function errorHandler(error: FastifyError, request: FastifyRequest, reply: FastifyReply) {
|
||||
// Log error
|
||||
request.log.error(error);
|
||||
|
||||
// Handle custom app errors
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
error: error.name,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (error instanceof ZodError) {
|
||||
return reply.status(400).send({
|
||||
error: 'ValidationError',
|
||||
message: 'Invalid request data',
|
||||
details: error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Fastify validation errors
|
||||
if (error.validation) {
|
||||
return reply.status(400).send({
|
||||
error: 'ValidationError',
|
||||
message: error.message,
|
||||
details: error.validation,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Prisma errors
|
||||
if (error.name === 'PrismaClientKnownRequestError') {
|
||||
const prismaError = error as any;
|
||||
if (prismaError.code === 'P2002') {
|
||||
return reply.status(409).send({
|
||||
error: 'ConflictError',
|
||||
message: 'A record with this value already exists',
|
||||
});
|
||||
}
|
||||
if (prismaError.code === 'P2025') {
|
||||
return reply.status(404).send({
|
||||
error: 'NotFoundError',
|
||||
message: 'Record not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default server error
|
||||
const statusCode = error.statusCode || 500;
|
||||
const message = statusCode === 500 ? 'Internal server error' : error.message;
|
||||
|
||||
return reply.status(statusCode).send({
|
||||
error: 'ServerError',
|
||||
message,
|
||||
});
|
||||
}
|
||||
49
backend-api/src/repositories/AssetRepository.ts
Normal file
49
backend-api/src/repositories/AssetRepository.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {Asset, Prisma} from '@prisma/client';
|
||||
import {prisma} from '../config/database';
|
||||
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||
|
||||
/**
|
||||
* Asset Repository
|
||||
* Implements Single Responsibility: Only handles Asset data access
|
||||
*/
|
||||
export class AssetRepository implements IUserScopedRepository<Asset> {
|
||||
async findById(id: string): Promise<Asset | null> {
|
||||
return prisma.asset.findUnique({where: {id}});
|
||||
}
|
||||
|
||||
async findByIdAndUser(id: string, userId: string): Promise<Asset | null> {
|
||||
return prisma.asset.findFirst({
|
||||
where: {id, userId},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string, filters?: Record<string, any>): Promise<Asset[]> {
|
||||
return prisma.asset.findMany({
|
||||
where: {userId, ...filters},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.AssetCreateInput): Promise<Asset> {
|
||||
return prisma.asset.create({data});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.AssetUpdateInput): Promise<Asset> {
|
||||
return prisma.asset.update({
|
||||
where: {id},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.asset.delete({where: {id}});
|
||||
}
|
||||
|
||||
async getTotalValue(userId: string): Promise<number> {
|
||||
const result = await prisma.asset.aggregate({
|
||||
where: {userId},
|
||||
_sum: {value: true},
|
||||
});
|
||||
return result._sum.value || 0;
|
||||
}
|
||||
}
|
||||
149
backend-api/src/repositories/CashflowRepository.ts
Normal file
149
backend-api/src/repositories/CashflowRepository.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {IncomeSource, Expense, Transaction, Prisma} from '@prisma/client';
|
||||
import {DatabaseConnection} from '../config/database';
|
||||
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||
|
||||
const prisma = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* Repository for IncomeSource data access
|
||||
*/
|
||||
export class IncomeSourceRepository implements IUserScopedRepository<IncomeSource> {
|
||||
async findById(id: string): Promise<IncomeSource | null> {
|
||||
return prisma.incomeSource.findUnique({where: {id}});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<IncomeSource[]> {
|
||||
return prisma.incomeSource.findMany({
|
||||
where: {userId},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.IncomeSourceCreateInput): Promise<IncomeSource> {
|
||||
return prisma.incomeSource.create({data});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.IncomeSourceUpdateInput): Promise<IncomeSource> {
|
||||
return prisma.incomeSource.update({where: {id}, data});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.incomeSource.delete({where: {id}});
|
||||
}
|
||||
|
||||
async getTotalMonthlyIncome(userId: string): Promise<number> {
|
||||
const result = await prisma.incomeSource.aggregate({
|
||||
where: {userId},
|
||||
_sum: {amount: true},
|
||||
});
|
||||
return result._sum.amount || 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for Expense data access
|
||||
*/
|
||||
export class ExpenseRepository implements IUserScopedRepository<Expense> {
|
||||
async findById(id: string): Promise<Expense | null> {
|
||||
return prisma.expense.findUnique({where: {id}});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Expense[]> {
|
||||
return prisma.expense.findMany({
|
||||
where: {userId},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.ExpenseCreateInput): Promise<Expense> {
|
||||
return prisma.expense.create({data});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.ExpenseUpdateInput): Promise<Expense> {
|
||||
return prisma.expense.update({where: {id}, data});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.expense.delete({where: {id}});
|
||||
}
|
||||
|
||||
async getTotalMonthlyExpenses(userId: string): Promise<number> {
|
||||
const result = await prisma.expense.aggregate({
|
||||
where: {userId},
|
||||
_sum: {amount: true},
|
||||
});
|
||||
return result._sum.amount || 0;
|
||||
}
|
||||
|
||||
async getByCategory(userId: string): Promise<Record<string, Expense[]>> {
|
||||
const expenses = await this.findAllByUser(userId);
|
||||
return expenses.reduce((acc, expense) => {
|
||||
if (!acc[expense.category]) acc[expense.category] = [];
|
||||
acc[expense.category].push(expense);
|
||||
return acc;
|
||||
}, {} as Record<string, Expense[]>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for Transaction data access
|
||||
*/
|
||||
export class TransactionRepository implements IUserScopedRepository<Transaction> {
|
||||
async findById(id: string): Promise<Transaction | null> {
|
||||
return prisma.transaction.findUnique({where: {id}});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Transaction[]> {
|
||||
return prisma.transaction.findMany({
|
||||
where: {userId},
|
||||
orderBy: {date: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.TransactionCreateInput): Promise<Transaction> {
|
||||
return prisma.transaction.create({data});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.transaction.delete({where: {id}});
|
||||
}
|
||||
|
||||
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
|
||||
return prisma.transaction.findMany({
|
||||
where: {
|
||||
userId,
|
||||
date: {gte: startDate, lte: endDate},
|
||||
},
|
||||
orderBy: {date: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async getByType(userId: string, type: string): Promise<Transaction[]> {
|
||||
return prisma.transaction.findMany({
|
||||
where: {userId, type},
|
||||
orderBy: {date: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async getCashflowSummary(userId: string, startDate: Date, endDate: Date): Promise<{
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
netCashflow: number;
|
||||
}> {
|
||||
const transactions = await this.getByDateRange(userId, startDate, endDate);
|
||||
|
||||
const totalIncome = transactions
|
||||
.filter(t => t.type === 'INCOME')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const totalExpenses = transactions
|
||||
.filter(t => t.type === 'EXPENSE')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpenses,
|
||||
netCashflow: totalIncome - totalExpenses,
|
||||
};
|
||||
}
|
||||
}
|
||||
121
backend-api/src/repositories/ClientRepository.ts
Normal file
121
backend-api/src/repositories/ClientRepository.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {Client, Prisma} from '@prisma/client';
|
||||
import {DatabaseConnection} from '../config/database';
|
||||
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||
|
||||
const prisma = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* Repository for Client data access
|
||||
* Implements Single Responsibility Principle - handles only database operations
|
||||
*/
|
||||
export class ClientRepository implements IUserScopedRepository<Client> {
|
||||
async findById(id: string): Promise<Client | null> {
|
||||
return prisma.client.findUnique({
|
||||
where: {id},
|
||||
include: {
|
||||
invoices: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Client[]> {
|
||||
return prisma.client.findMany({
|
||||
where: {userId},
|
||||
include: {
|
||||
invoices: {
|
||||
orderBy: {createdAt: 'desc'},
|
||||
},
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.ClientCreateInput): Promise<Client> {
|
||||
return prisma.client.create({
|
||||
data,
|
||||
include: {
|
||||
invoices: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.ClientUpdateInput): Promise<Client> {
|
||||
return prisma.client.update({
|
||||
where: {id},
|
||||
data,
|
||||
include: {
|
||||
invoices: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.client.delete({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find client by email
|
||||
*/
|
||||
async findByEmail(userId: string, email: string): Promise<Client | null> {
|
||||
return prisma.client.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total revenue from all clients
|
||||
*/
|
||||
async getTotalRevenue(userId: string): Promise<number> {
|
||||
const result = await prisma.invoice.aggregate({
|
||||
where: {
|
||||
client: {
|
||||
userId,
|
||||
},
|
||||
status: 'PAID',
|
||||
},
|
||||
_sum: {
|
||||
total: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.total || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients with their invoice statistics
|
||||
*/
|
||||
async getWithStats(userId: string): Promise<any[]> {
|
||||
const clients = await prisma.client.findMany({
|
||||
where: {userId},
|
||||
include: {
|
||||
invoices: {
|
||||
select: {
|
||||
id: true,
|
||||
total: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
|
||||
return clients.map(client => ({
|
||||
...client,
|
||||
stats: {
|
||||
totalInvoices: client.invoices.length,
|
||||
paidInvoices: client.invoices.filter(inv => inv.status === 'PAID').length,
|
||||
totalRevenue: client.invoices
|
||||
.filter(inv => inv.status === 'PAID')
|
||||
.reduce((sum, inv) => sum + inv.total, 0),
|
||||
outstandingAmount: client.invoices
|
||||
.filter(inv => inv.status !== 'PAID')
|
||||
.reduce((sum, inv) => sum + inv.total, 0),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
118
backend-api/src/repositories/DebtAccountRepository.ts
Normal file
118
backend-api/src/repositories/DebtAccountRepository.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {DebtAccount, Prisma} from '@prisma/client';
|
||||
import {DatabaseConnection} from '../config/database';
|
||||
|
||||
const prisma = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* Repository for DebtAccount data access
|
||||
* Implements Single Responsibility Principle - handles only database operations
|
||||
*/
|
||||
export class DebtAccountRepository {
|
||||
async findById(id: string): Promise<DebtAccount | null> {
|
||||
return prisma.debtAccount.findUnique({
|
||||
where: {id},
|
||||
include: {
|
||||
category: true,
|
||||
payments: {
|
||||
orderBy: {paymentDate: 'desc'},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<DebtAccount[]> {
|
||||
return prisma.debtAccount.findMany({
|
||||
where: {
|
||||
category: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
payments: {
|
||||
orderBy: {paymentDate: 'desc'},
|
||||
},
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async findByCategory(categoryId: string): Promise<DebtAccount[]> {
|
||||
return prisma.debtAccount.findMany({
|
||||
where: {categoryId},
|
||||
include: {
|
||||
payments: {
|
||||
orderBy: {paymentDate: 'desc'},
|
||||
},
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.DebtAccountCreateInput): Promise<DebtAccount> {
|
||||
return prisma.debtAccount.create({
|
||||
data,
|
||||
include: {
|
||||
category: true,
|
||||
payments: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.DebtAccountUpdateInput): Promise<DebtAccount> {
|
||||
return prisma.debtAccount.update({
|
||||
where: {id},
|
||||
data,
|
||||
include: {
|
||||
category: true,
|
||||
payments: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.debtAccount.delete({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total debt across all accounts for a user
|
||||
*/
|
||||
async getTotalDebt(userId: string): Promise<number> {
|
||||
const result = await prisma.debtAccount.aggregate({
|
||||
where: {
|
||||
category: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
currentBalance: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.currentBalance || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts with payment statistics
|
||||
*/
|
||||
async getWithStats(userId: string): Promise<any[]> {
|
||||
const accounts = await this.findAllByUser(userId);
|
||||
|
||||
return accounts.map(account => {
|
||||
const totalPaid = account.payments.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
const lastPayment = account.payments[0];
|
||||
|
||||
return {
|
||||
...account,
|
||||
stats: {
|
||||
totalPaid,
|
||||
numberOfPayments: account.payments.length,
|
||||
lastPaymentDate: lastPayment?.paymentDate || null,
|
||||
lastPaymentAmount: lastPayment?.amount || null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
117
backend-api/src/repositories/DebtCategoryRepository.ts
Normal file
117
backend-api/src/repositories/DebtCategoryRepository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {DebtCategory, Prisma} from '@prisma/client';
|
||||
import {DatabaseConnection} from '../config/database';
|
||||
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||
|
||||
const prisma = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* Repository for DebtCategory data access
|
||||
* Implements Single Responsibility Principle - handles only database operations
|
||||
*/
|
||||
export class DebtCategoryRepository implements IUserScopedRepository<DebtCategory> {
|
||||
async findById(id: string): Promise<DebtCategory | null> {
|
||||
return prisma.debtCategory.findUnique({
|
||||
where: {id},
|
||||
include: {
|
||||
accounts: {
|
||||
include: {
|
||||
payments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<DebtCategory[]> {
|
||||
return prisma.debtCategory.findMany({
|
||||
where: {userId},
|
||||
include: {
|
||||
accounts: {
|
||||
include: {
|
||||
payments: true,
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
},
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.DebtCategoryCreateInput): Promise<DebtCategory> {
|
||||
return prisma.debtCategory.create({
|
||||
data,
|
||||
include: {
|
||||
accounts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.DebtCategoryUpdateInput): Promise<DebtCategory> {
|
||||
return prisma.debtCategory.update({
|
||||
where: {id},
|
||||
data,
|
||||
include: {
|
||||
accounts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.debtCategory.delete({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find category by name
|
||||
*/
|
||||
async findByName(userId: string, name: string): Promise<DebtCategory | null> {
|
||||
return prisma.debtCategory.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total debt across all accounts in a category
|
||||
*/
|
||||
async getTotalDebt(categoryId: string): Promise<number> {
|
||||
const result = await prisma.debtAccount.aggregate({
|
||||
where: {categoryId},
|
||||
_sum: {
|
||||
currentBalance: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.currentBalance || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories with debt statistics
|
||||
*/
|
||||
async getWithStats(userId: string): Promise<any[]> {
|
||||
const categories = await this.findAllByUser(userId);
|
||||
|
||||
return Promise.all(
|
||||
categories.map(async category => {
|
||||
const totalDebt = await this.getTotalDebt(category.id);
|
||||
const totalPayments = category.accounts.reduce(
|
||||
(sum, account) =>
|
||||
sum + account.payments.reduce((pSum, payment) => pSum + payment.amount, 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
...category,
|
||||
stats: {
|
||||
totalAccounts: category.accounts.length,
|
||||
totalDebt,
|
||||
totalPayments,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
130
backend-api/src/repositories/DebtPaymentRepository.ts
Normal file
130
backend-api/src/repositories/DebtPaymentRepository.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {DebtPayment, Prisma} from '@prisma/client';
|
||||
import {DatabaseConnection} from '../config/database';
|
||||
|
||||
const prisma = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* Repository for DebtPayment data access
|
||||
* Implements Single Responsibility Principle - handles only database operations
|
||||
*/
|
||||
export class DebtPaymentRepository {
|
||||
async findById(id: string): Promise<DebtPayment | null> {
|
||||
return prisma.debtPayment.findUnique({
|
||||
where: {id},
|
||||
include: {
|
||||
account: {
|
||||
include: {
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByAccount(accountId: string): Promise<DebtPayment[]> {
|
||||
return prisma.debtPayment.findMany({
|
||||
where: {accountId},
|
||||
orderBy: {paymentDate: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<DebtPayment[]> {
|
||||
return prisma.debtPayment.findMany({
|
||||
where: {
|
||||
account: {
|
||||
category: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
account: {
|
||||
include: {
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {paymentDate: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.DebtPaymentCreateInput): Promise<DebtPayment> {
|
||||
return prisma.debtPayment.create({
|
||||
data,
|
||||
include: {
|
||||
account: {
|
||||
include: {
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.debtPayment.delete({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total payments for an account
|
||||
*/
|
||||
async getTotalPayments(accountId: string): Promise<number> {
|
||||
const result = await prisma.debtPayment.aggregate({
|
||||
where: {accountId},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.amount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total payments for a user
|
||||
*/
|
||||
async getTotalPaymentsByUser(userId: string): Promise<number> {
|
||||
const result = await prisma.debtPayment.aggregate({
|
||||
where: {
|
||||
account: {
|
||||
category: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.amount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payments within a date range
|
||||
*/
|
||||
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<DebtPayment[]> {
|
||||
return prisma.debtPayment.findMany({
|
||||
where: {
|
||||
account: {
|
||||
category: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
paymentDate: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
account: {
|
||||
include: {
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {paymentDate: 'desc'},
|
||||
});
|
||||
}
|
||||
}
|
||||
76
backend-api/src/repositories/InvoiceRepository.ts
Normal file
76
backend-api/src/repositories/InvoiceRepository.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {Invoice, Prisma, InvoiceStatus} from '@prisma/client';
|
||||
import {prisma} from '../config/database';
|
||||
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||
|
||||
type InvoiceWithLineItems = Prisma.InvoiceGetPayload<{
|
||||
include: {lineItems: true; client: true};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Invoice Repository
|
||||
* Handles Invoice data access with relationships
|
||||
*/
|
||||
export class InvoiceRepository implements IUserScopedRepository<Invoice> {
|
||||
async findById(id: string): Promise<Invoice | null> {
|
||||
return prisma.invoice.findUnique({
|
||||
where: {id},
|
||||
include: {lineItems: true, client: true},
|
||||
}) as unknown as Invoice;
|
||||
}
|
||||
|
||||
async findByIdAndUser(id: string, userId: string): Promise<InvoiceWithLineItems | null> {
|
||||
return prisma.invoice.findFirst({
|
||||
where: {id, userId},
|
||||
include: {lineItems: true, client: true},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string, filters?: {status?: InvoiceStatus}): Promise<InvoiceWithLineItems[]> {
|
||||
return prisma.invoice.findMany({
|
||||
where: {userId, ...filters},
|
||||
include: {lineItems: true, client: true},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.InvoiceCreateInput): Promise<Invoice> {
|
||||
return prisma.invoice.create({
|
||||
data,
|
||||
include: {lineItems: true, client: true},
|
||||
}) as unknown as Invoice;
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.InvoiceUpdateInput): Promise<Invoice> {
|
||||
return prisma.invoice.update({
|
||||
where: {id},
|
||||
data,
|
||||
include: {lineItems: true, client: true},
|
||||
}) as unknown as Invoice;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.invoice.delete({where: {id}});
|
||||
}
|
||||
|
||||
async invoiceNumberExists(userId: string, invoiceNumber: string, excludeId?: string): Promise<boolean> {
|
||||
const count = await prisma.invoice.count({
|
||||
where: {
|
||||
userId,
|
||||
invoiceNumber,
|
||||
...(excludeId && {id: {not: excludeId}}),
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async generateInvoiceNumber(userId: string): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await prisma.invoice.count({
|
||||
where: {
|
||||
userId,
|
||||
invoiceNumber: {startsWith: `INV-${year}-`},
|
||||
},
|
||||
});
|
||||
return `INV-${year}-${String(count + 1).padStart(3, '0')}`;
|
||||
}
|
||||
}
|
||||
73
backend-api/src/repositories/LiabilityRepository.ts
Normal file
73
backend-api/src/repositories/LiabilityRepository.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {Liability, Prisma} from '@prisma/client';
|
||||
import {DatabaseConnection} from '../config/database';
|
||||
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||
|
||||
const prisma = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* Repository for Liability data access
|
||||
* Implements Single Responsibility Principle - handles only database operations
|
||||
*/
|
||||
export class LiabilityRepository implements IUserScopedRepository<Liability> {
|
||||
async findById(id: string): Promise<Liability | null> {
|
||||
return prisma.liability.findUnique({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Liability[]> {
|
||||
return prisma.liability.findMany({
|
||||
where: {userId},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.LiabilityCreateInput): Promise<Liability> {
|
||||
return prisma.liability.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.LiabilityUpdateInput): Promise<Liability> {
|
||||
return prisma.liability.update({
|
||||
where: {id},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.liability.delete({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total value of all liabilities for a user
|
||||
*/
|
||||
async getTotalValue(userId: string): Promise<number> {
|
||||
const result = await prisma.liability.aggregate({
|
||||
where: {userId},
|
||||
_sum: {
|
||||
currentBalance: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.currentBalance || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get liabilities grouped by type
|
||||
*/
|
||||
async getByType(userId: string): Promise<Record<string, Liability[]>> {
|
||||
const liabilities = await this.findAllByUser(userId);
|
||||
|
||||
return liabilities.reduce((acc, liability) => {
|
||||
const type = liability.type;
|
||||
if (!acc[type]) {
|
||||
acc[type] = [];
|
||||
}
|
||||
acc[type].push(liability);
|
||||
return acc;
|
||||
}, {} as Record<string, Liability[]>);
|
||||
}
|
||||
}
|
||||
112
backend-api/src/repositories/NetWorthSnapshotRepository.ts
Normal file
112
backend-api/src/repositories/NetWorthSnapshotRepository.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {NetWorthSnapshot, Prisma} from '@prisma/client';
|
||||
import {DatabaseConnection} from '../config/database';
|
||||
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||
|
||||
const prisma = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* Repository for NetWorthSnapshot data access
|
||||
* Implements Single Responsibility Principle - handles only database operations
|
||||
*/
|
||||
export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWorthSnapshot> {
|
||||
async findById(id: string): Promise<NetWorthSnapshot | null> {
|
||||
return prisma.netWorthSnapshot.findUnique({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<NetWorthSnapshot[]> {
|
||||
return prisma.netWorthSnapshot.findMany({
|
||||
where: {userId},
|
||||
orderBy: {date: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.NetWorthSnapshotCreateInput): Promise<NetWorthSnapshot> {
|
||||
return prisma.netWorthSnapshot.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.NetWorthSnapshotUpdateInput): Promise<NetWorthSnapshot> {
|
||||
return prisma.netWorthSnapshot.update({
|
||||
where: {id},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.netWorthSnapshot.delete({
|
||||
where: {id},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest snapshot for a user
|
||||
*/
|
||||
async getLatest(userId: string): Promise<NetWorthSnapshot | null> {
|
||||
return prisma.netWorthSnapshot.findFirst({
|
||||
where: {userId},
|
||||
orderBy: {date: 'desc'},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshots within a date range
|
||||
*/
|
||||
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<NetWorthSnapshot[]> {
|
||||
return prisma.netWorthSnapshot.findMany({
|
||||
where: {
|
||||
userId,
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
orderBy: {date: 'asc'},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists for a specific date
|
||||
*/
|
||||
async existsForDate(userId: string, date: Date): Promise<boolean> {
|
||||
const count = await prisma.netWorthSnapshot.count({
|
||||
where: {
|
||||
userId,
|
||||
date,
|
||||
},
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get growth over time (percentage change between snapshots)
|
||||
*/
|
||||
async getGrowthStats(userId: string, limit: number = 12): Promise<any[]> {
|
||||
const snapshots = await prisma.netWorthSnapshot.findMany({
|
||||
where: {userId},
|
||||
orderBy: {date: 'desc'},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const stats = [];
|
||||
for (let i = 0; i < snapshots.length - 1; i++) {
|
||||
const current = snapshots[i];
|
||||
const previous = snapshots[i + 1];
|
||||
const growthAmount = current.netWorth - previous.netWorth;
|
||||
const growthPercent =
|
||||
previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0;
|
||||
|
||||
stats.push({
|
||||
date: current.date,
|
||||
netWorth: current.netWorth,
|
||||
growthAmount,
|
||||
growthPercent: parseFloat(growthPercent.toFixed(2)),
|
||||
});
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
51
backend-api/src/repositories/UserRepository.ts
Normal file
51
backend-api/src/repositories/UserRepository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {User, Prisma} from '@prisma/client';
|
||||
import {prisma} from '../config/database';
|
||||
import {IRepository} from './interfaces/IRepository';
|
||||
|
||||
/**
|
||||
* User Repository
|
||||
* Implements Single Responsibility: Only handles User data access
|
||||
* Implements Dependency Inversion: Implements IRepository interface
|
||||
*/
|
||||
export class UserRepository implements IRepository<User> {
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return prisma.user.findUnique({where: {id}});
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return prisma.user.findUnique({where: {email}});
|
||||
}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
password: false, // Never return password
|
||||
},
|
||||
}) as unknown as User[];
|
||||
}
|
||||
|
||||
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||
return prisma.user.create({data});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
|
||||
return prisma.user.update({
|
||||
where: {id},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.user.delete({where: {id}});
|
||||
}
|
||||
|
||||
async emailExists(email: string): Promise<boolean> {
|
||||
const count = await prisma.user.count({where: {email}});
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
21
backend-api/src/repositories/interfaces/IRepository.ts
Normal file
21
backend-api/src/repositories/interfaces/IRepository.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Generic Repository Interface
|
||||
* Implements Interface Segregation: Base interface for common operations
|
||||
* Implements Dependency Inversion: Depend on abstractions, not concretions
|
||||
*/
|
||||
export interface IRepository<T> {
|
||||
findById(id: string): Promise<T | null>;
|
||||
findAll(filters?: Record<string, any>): Promise<T[]>;
|
||||
create(data: Partial<T>): Promise<T>;
|
||||
update(id: string, data: Partial<T>): Promise<T>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-scoped repository interface
|
||||
* For entities that belong to a specific user
|
||||
*/
|
||||
export interface IUserScopedRepository<T> extends Omit<IRepository<T>, 'findAll'> {
|
||||
findAllByUser(userId: string, filters?: Record<string, any>): Promise<T[]>;
|
||||
findByIdAndUser(id: string, userId: string): Promise<T | null>;
|
||||
}
|
||||
94
backend-api/src/routes/assets.ts
Normal file
94
backend-api/src/routes/assets.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {AssetController} from '../controllers/AssetController';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
/**
|
||||
* Asset Routes
|
||||
* All routes require authentication
|
||||
*/
|
||||
export async function assetRoutes(fastify: FastifyInstance) {
|
||||
const controller = new AssetController();
|
||||
|
||||
// Apply authentication to all routes
|
||||
fastify.addHook('preHandler', authenticate);
|
||||
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['Assets'],
|
||||
description: 'Get all user assets',
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
handler: controller.getAll.bind(controller),
|
||||
});
|
||||
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
tags: ['Assets'],
|
||||
description: 'Get asset by ID',
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string', format: 'uuid'},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: controller.getById.bind(controller),
|
||||
});
|
||||
|
||||
fastify.post('/', {
|
||||
schema: {
|
||||
tags: ['Assets'],
|
||||
description: 'Create a new asset',
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name', 'type', 'value'],
|
||||
properties: {
|
||||
name: {type: 'string'},
|
||||
type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']},
|
||||
value: {type: 'number', minimum: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: controller.create.bind(controller),
|
||||
});
|
||||
|
||||
fastify.put('/:id', {
|
||||
schema: {
|
||||
tags: ['Assets'],
|
||||
description: 'Update an asset',
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string', format: 'uuid'},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {type: 'string'},
|
||||
type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']},
|
||||
value: {type: 'number', minimum: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: controller.update.bind(controller),
|
||||
});
|
||||
|
||||
fastify.delete('/:id', {
|
||||
schema: {
|
||||
tags: ['Assets'],
|
||||
description: 'Delete an asset',
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string', format: 'uuid'},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: controller.delete.bind(controller),
|
||||
});
|
||||
}
|
||||
53
backend-api/src/routes/auth.ts
Normal file
53
backend-api/src/routes/auth.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {AuthController} from '../controllers/AuthController';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
/**
|
||||
* Authentication Routes
|
||||
*/
|
||||
export async function authRoutes(fastify: FastifyInstance) {
|
||||
const controller = new AuthController();
|
||||
|
||||
fastify.post('/register', {
|
||||
schema: {
|
||||
tags: ['Authentication'],
|
||||
description: 'Register a new user',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password', 'name'],
|
||||
properties: {
|
||||
email: {type: 'string', format: 'email'},
|
||||
password: {type: 'string', minLength: 8},
|
||||
name: {type: 'string', minLength: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: controller.register.bind(controller),
|
||||
});
|
||||
|
||||
fastify.post('/login', {
|
||||
schema: {
|
||||
tags: ['Authentication'],
|
||||
description: 'Login with email and password',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: {type: 'string', format: 'email'},
|
||||
password: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: controller.login.bind(controller),
|
||||
});
|
||||
|
||||
fastify.get('/profile', {
|
||||
schema: {
|
||||
tags: ['Authentication'],
|
||||
description: 'Get current user profile',
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
preHandler: authenticate,
|
||||
handler: controller.getProfile.bind(controller),
|
||||
});
|
||||
}
|
||||
217
backend-api/src/routes/cashflow.routes.ts
Normal file
217
backend-api/src/routes/cashflow.routes.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {CashflowController} from '../controllers/CashflowController';
|
||||
import {CashflowService} from '../services/CashflowService';
|
||||
import {
|
||||
IncomeSourceRepository,
|
||||
ExpenseRepository,
|
||||
TransactionRepository,
|
||||
} from '../repositories/CashflowRepository';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
const incomeRepository = new IncomeSourceRepository();
|
||||
const expenseRepository = new ExpenseRepository();
|
||||
const transactionRepository = new TransactionRepository();
|
||||
const cashflowService = new CashflowService(incomeRepository, expenseRepository, transactionRepository);
|
||||
const cashflowController = new CashflowController(cashflowService);
|
||||
|
||||
export async function cashflowRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
|
||||
// ===== Income Source Routes =====
|
||||
|
||||
fastify.get('/income', {
|
||||
schema: {
|
||||
description: 'Get all income sources',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.getAllIncome.bind(cashflowController));
|
||||
|
||||
fastify.get('/income/total', {
|
||||
schema: {
|
||||
description: 'Get total monthly income',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.getTotalMonthlyIncome.bind(cashflowController));
|
||||
|
||||
fastify.get('/income/:id', {
|
||||
schema: {
|
||||
description: 'Get income source by ID',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.getOneIncome.bind(cashflowController));
|
||||
|
||||
fastify.post('/income', {
|
||||
schema: {
|
||||
description: 'Create income source',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name', 'amount', 'frequency'],
|
||||
properties: {
|
||||
name: {type: 'string'},
|
||||
amount: {type: 'number'},
|
||||
frequency: {type: 'string'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cashflowController.createIncome.bind(cashflowController));
|
||||
|
||||
fastify.put('/income/:id', {
|
||||
schema: {
|
||||
description: 'Update income source',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.updateIncome.bind(cashflowController));
|
||||
|
||||
fastify.delete('/income/:id', {
|
||||
schema: {
|
||||
description: 'Delete income source',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.deleteIncome.bind(cashflowController));
|
||||
|
||||
// ===== Expense Routes =====
|
||||
|
||||
fastify.get('/expenses', {
|
||||
schema: {
|
||||
description: 'Get all expenses',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
byCategory: {type: 'string', enum: ['true', 'false']},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cashflowController.getAllExpenses.bind(cashflowController));
|
||||
|
||||
fastify.get('/expenses/total', {
|
||||
schema: {
|
||||
description: 'Get total monthly expenses',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.getTotalMonthlyExpenses.bind(cashflowController));
|
||||
|
||||
fastify.get('/expenses/:id', {
|
||||
schema: {
|
||||
description: 'Get expense by ID',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.getOneExpense.bind(cashflowController));
|
||||
|
||||
fastify.post('/expenses', {
|
||||
schema: {
|
||||
description: 'Create expense',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name', 'amount', 'category', 'frequency'],
|
||||
properties: {
|
||||
name: {type: 'string'},
|
||||
amount: {type: 'number'},
|
||||
category: {type: 'string'},
|
||||
frequency: {type: 'string'},
|
||||
dueDate: {type: 'string', format: 'date-time'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cashflowController.createExpense.bind(cashflowController));
|
||||
|
||||
fastify.put('/expenses/:id', {
|
||||
schema: {
|
||||
description: 'Update expense',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.updateExpense.bind(cashflowController));
|
||||
|
||||
fastify.delete('/expenses/:id', {
|
||||
schema: {
|
||||
description: 'Delete expense',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.deleteExpense.bind(cashflowController));
|
||||
|
||||
// ===== Transaction Routes =====
|
||||
|
||||
fastify.get('/transactions', {
|
||||
schema: {
|
||||
description: 'Get all transactions',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {type: 'string'},
|
||||
startDate: {type: 'string', format: 'date-time'},
|
||||
endDate: {type: 'string', format: 'date-time'},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cashflowController.getAllTransactions.bind(cashflowController));
|
||||
|
||||
fastify.get('/transactions/summary', {
|
||||
schema: {
|
||||
description: 'Get cashflow summary for date range',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['startDate', 'endDate'],
|
||||
properties: {
|
||||
startDate: {type: 'string', format: 'date-time'},
|
||||
endDate: {type: 'string', format: 'date-time'},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cashflowController.getCashflowSummary.bind(cashflowController));
|
||||
|
||||
fastify.get('/transactions/:id', {
|
||||
schema: {
|
||||
description: 'Get transaction by ID',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.getOneTransaction.bind(cashflowController));
|
||||
|
||||
fastify.post('/transactions', {
|
||||
schema: {
|
||||
description: 'Create transaction',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['type', 'category', 'amount', 'date'],
|
||||
properties: {
|
||||
type: {type: 'string'},
|
||||
category: {type: 'string'},
|
||||
amount: {type: 'number'},
|
||||
date: {type: 'string', format: 'date-time'},
|
||||
description: {type: 'string'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, cashflowController.createTransaction.bind(cashflowController));
|
||||
|
||||
fastify.delete('/transactions/:id', {
|
||||
schema: {
|
||||
description: 'Delete transaction',
|
||||
tags: ['Cashflow'],
|
||||
security: [{bearerAuth: []}],
|
||||
},
|
||||
}, cashflowController.deleteTransaction.bind(cashflowController));
|
||||
}
|
||||
231
backend-api/src/routes/client.routes.ts
Normal file
231
backend-api/src/routes/client.routes.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {ClientController} from '../controllers/ClientController';
|
||||
import {ClientService} from '../services/ClientService';
|
||||
import {ClientRepository} from '../repositories/ClientRepository';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
const clientRepository = new ClientRepository();
|
||||
const clientService = new ClientService(clientRepository);
|
||||
const clientController = new ClientController(clientService);
|
||||
|
||||
export async function clientRoutes(fastify: FastifyInstance) {
|
||||
// Apply authentication to all routes
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
|
||||
/**
|
||||
* Get all clients
|
||||
*/
|
||||
fastify.get(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all clients for the authenticated user',
|
||||
tags: ['Clients'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withStats: {
|
||||
type: 'string',
|
||||
enum: ['true', 'false'],
|
||||
description: 'Include invoice statistics for each client',
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of clients',
|
||||
type: 'object',
|
||||
properties: {
|
||||
clients: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
name: {type: 'string'},
|
||||
email: {type: 'string'},
|
||||
phone: {type: 'string', nullable: true},
|
||||
address: {type: 'string', nullable: true},
|
||||
notes: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clientController.getAll.bind(clientController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get total revenue
|
||||
*/
|
||||
fastify.get(
|
||||
'/revenue/total',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get total revenue from all paid client invoices',
|
||||
tags: ['Clients'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Total revenue',
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalRevenue: {type: 'number'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clientController.getTotalRevenue.bind(clientController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single client
|
||||
*/
|
||||
fastify.get(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get a single client by ID',
|
||||
tags: ['Clients'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Client details',
|
||||
type: 'object',
|
||||
properties: {
|
||||
client: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
name: {type: 'string'},
|
||||
email: {type: 'string'},
|
||||
phone: {type: 'string', nullable: true},
|
||||
address: {type: 'string', nullable: true},
|
||||
notes: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clientController.getOne.bind(clientController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create client
|
||||
*/
|
||||
fastify.post(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a new client',
|
||||
tags: ['Clients'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name', 'email'],
|
||||
properties: {
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
email: {type: 'string', format: 'email'},
|
||||
phone: {type: 'string', maxLength: 50},
|
||||
address: {type: 'string'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Client created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
client: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clientController.create.bind(clientController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Update client
|
||||
*/
|
||||
fastify.put(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Update a client',
|
||||
tags: ['Clients'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
email: {type: 'string', format: 'email'},
|
||||
phone: {type: 'string', maxLength: 50},
|
||||
address: {type: 'string'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Client updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
client: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clientController.update.bind(clientController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete client
|
||||
*/
|
||||
fastify.delete(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete a client',
|
||||
tags: ['Clients'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Client deleted successfully',
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clientController.delete.bind(clientController)
|
||||
);
|
||||
}
|
||||
106
backend-api/src/routes/dashboard.routes.ts
Normal file
106
backend-api/src/routes/dashboard.routes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {DashboardController} from '../controllers/DashboardController';
|
||||
import {DashboardService} from '../services/DashboardService';
|
||||
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';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
const assetRepository = new AssetRepository();
|
||||
const liabilityRepository = new LiabilityRepository();
|
||||
const invoiceRepository = new InvoiceRepository();
|
||||
const debtAccountRepository = new DebtAccountRepository();
|
||||
const incomeRepository = new IncomeSourceRepository();
|
||||
const expenseRepository = new ExpenseRepository();
|
||||
const transactionRepository = new TransactionRepository();
|
||||
const snapshotRepository = new NetWorthSnapshotRepository();
|
||||
|
||||
const dashboardService = new DashboardService(
|
||||
assetRepository,
|
||||
liabilityRepository,
|
||||
invoiceRepository,
|
||||
debtAccountRepository,
|
||||
incomeRepository,
|
||||
expenseRepository,
|
||||
transactionRepository,
|
||||
snapshotRepository
|
||||
);
|
||||
|
||||
const dashboardController = new DashboardController(dashboardService);
|
||||
|
||||
export async function dashboardRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
|
||||
/**
|
||||
* Get dashboard summary
|
||||
*/
|
||||
fastify.get(
|
||||
'/summary',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get comprehensive financial dashboard summary',
|
||||
tags: ['Dashboard'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Dashboard summary data',
|
||||
type: 'object',
|
||||
properties: {
|
||||
netWorth: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
current: {type: 'number'},
|
||||
assets: {type: 'number'},
|
||||
liabilities: {type: 'number'},
|
||||
change: {type: 'number'},
|
||||
lastUpdated: {type: 'string'},
|
||||
},
|
||||
},
|
||||
invoices: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {type: 'number'},
|
||||
paid: {type: 'number'},
|
||||
outstanding: {type: 'number'},
|
||||
overdue: {type: 'number'},
|
||||
},
|
||||
},
|
||||
debts: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {type: 'number'},
|
||||
accounts: {type: 'number'},
|
||||
},
|
||||
},
|
||||
cashflow: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
monthlyIncome: {type: 'number'},
|
||||
monthlyExpenses: {type: 'number'},
|
||||
monthlyNet: {type: 'number'},
|
||||
last30Days: {type: 'object'},
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {type: 'number'},
|
||||
count: {type: 'number'},
|
||||
allocation: {type: 'array'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboardController.getSummary.bind(dashboardController)
|
||||
);
|
||||
}
|
||||
559
backend-api/src/routes/debt.routes.ts
Normal file
559
backend-api/src/routes/debt.routes.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {DebtCategoryController} from '../controllers/DebtCategoryController';
|
||||
import {DebtCategoryService} from '../services/DebtCategoryService';
|
||||
import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository';
|
||||
import {DebtAccountController} from '../controllers/DebtAccountController';
|
||||
import {DebtAccountService} from '../services/DebtAccountService';
|
||||
import {DebtAccountRepository} from '../repositories/DebtAccountRepository';
|
||||
import {DebtPaymentController} from '../controllers/DebtPaymentController';
|
||||
import {DebtPaymentService} from '../services/DebtPaymentService';
|
||||
import {DebtPaymentRepository} from '../repositories/DebtPaymentRepository';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
const categoryRepository = new DebtCategoryRepository();
|
||||
const categoryService = new DebtCategoryService(categoryRepository);
|
||||
const categoryController = new DebtCategoryController(categoryService);
|
||||
|
||||
const accountRepository = new DebtAccountRepository();
|
||||
const accountService = new DebtAccountService(accountRepository, categoryRepository);
|
||||
const accountController = new DebtAccountController(accountService);
|
||||
|
||||
const paymentRepository = new DebtPaymentRepository();
|
||||
const paymentService = new DebtPaymentService(paymentRepository, accountRepository);
|
||||
const paymentController = new DebtPaymentController(paymentService);
|
||||
|
||||
export async function debtRoutes(fastify: FastifyInstance) {
|
||||
// Apply authentication to all routes
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
|
||||
/**
|
||||
* Get all debt categories
|
||||
*/
|
||||
fastify.get(
|
||||
'/categories',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all debt categories for the authenticated user',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withStats: {
|
||||
type: 'string',
|
||||
enum: ['true', 'false'],
|
||||
description: 'Include statistics for each category',
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of debt categories',
|
||||
type: 'object',
|
||||
properties: {
|
||||
categories: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
name: {type: 'string'},
|
||||
description: {type: 'string', nullable: true},
|
||||
color: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
categoryController.getAll.bind(categoryController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single debt category
|
||||
*/
|
||||
fastify.get(
|
||||
'/categories/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get a single debt category by ID',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Debt category details',
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
name: {type: 'string'},
|
||||
description: {type: 'string', nullable: true},
|
||||
color: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
categoryController.getOne.bind(categoryController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create debt category
|
||||
*/
|
||||
fastify.post(
|
||||
'/categories',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a new debt category',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
description: {type: 'string'},
|
||||
color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Debt category created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
categoryController.create.bind(categoryController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Update debt category
|
||||
*/
|
||||
fastify.put(
|
||||
'/categories/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Update a debt category',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
description: {type: 'string'},
|
||||
color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Debt category updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
categoryController.update.bind(categoryController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete debt category
|
||||
*/
|
||||
fastify.delete(
|
||||
'/categories/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete a debt category',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Debt category deleted successfully',
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
categoryController.delete.bind(categoryController)
|
||||
);
|
||||
|
||||
// ===== Debt Account Routes =====
|
||||
|
||||
/**
|
||||
* Get all debt accounts
|
||||
*/
|
||||
fastify.get(
|
||||
'/accounts',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all debt accounts for the authenticated user',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withStats: {type: 'string', enum: ['true', 'false']},
|
||||
categoryId: {type: 'string', description: 'Filter by category ID'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of debt accounts',
|
||||
type: 'object',
|
||||
properties: {
|
||||
accounts: {type: 'array', items: {type: 'object'}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountController.getAll.bind(accountController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get total debt
|
||||
*/
|
||||
fastify.get(
|
||||
'/accounts/total',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get total debt across all accounts',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Total debt',
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalDebt: {type: 'number'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountController.getTotalDebt.bind(accountController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single debt account
|
||||
*/
|
||||
fastify.get(
|
||||
'/accounts/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get a single debt account by ID',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Debt account details',
|
||||
type: 'object',
|
||||
properties: {
|
||||
account: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountController.getOne.bind(accountController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create debt account
|
||||
*/
|
||||
fastify.post(
|
||||
'/accounts',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a new debt account',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['categoryId', 'name', 'creditor', 'originalBalance', 'currentBalance'],
|
||||
properties: {
|
||||
categoryId: {type: 'string', format: 'uuid'},
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
creditor: {type: 'string', minLength: 1, maxLength: 255},
|
||||
accountNumber: {type: 'string', maxLength: 100},
|
||||
originalBalance: {type: 'number', minimum: 0},
|
||||
currentBalance: {type: 'number', minimum: 0},
|
||||
interestRate: {type: 'number', minimum: 0, maximum: 100},
|
||||
minimumPayment: {type: 'number', minimum: 0},
|
||||
dueDate: {type: 'string', format: 'date-time'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Debt account created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
account: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountController.create.bind(accountController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Update debt account
|
||||
*/
|
||||
fastify.put(
|
||||
'/accounts/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Update a debt account',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
creditor: {type: 'string', minLength: 1, maxLength: 255},
|
||||
accountNumber: {type: 'string', maxLength: 100},
|
||||
currentBalance: {type: 'number', minimum: 0},
|
||||
interestRate: {type: 'number', minimum: 0, maximum: 100},
|
||||
minimumPayment: {type: 'number', minimum: 0},
|
||||
dueDate: {type: 'string', format: 'date-time'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Debt account updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
account: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountController.update.bind(accountController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete debt account
|
||||
*/
|
||||
fastify.delete(
|
||||
'/accounts/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete a debt account',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Debt account deleted successfully',
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountController.delete.bind(accountController)
|
||||
);
|
||||
|
||||
// ===== Debt Payment Routes =====
|
||||
|
||||
/**
|
||||
* Get all debt payments
|
||||
*/
|
||||
fastify.get(
|
||||
'/payments',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all debt payments for the authenticated user',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accountId: {type: 'string', description: 'Filter by account ID'},
|
||||
startDate: {type: 'string', format: 'date-time'},
|
||||
endDate: {type: 'string', format: 'date-time'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of debt payments',
|
||||
type: 'object',
|
||||
properties: {
|
||||
payments: {type: 'array', items: {type: 'object'}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paymentController.getAll.bind(paymentController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get total payments
|
||||
*/
|
||||
fastify.get(
|
||||
'/payments/total',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get total payments made across all accounts',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Total payments',
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalPayments: {type: 'number'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paymentController.getTotalPayments.bind(paymentController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single debt payment
|
||||
*/
|
||||
fastify.get(
|
||||
'/payments/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get a single debt payment by ID',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Debt payment details',
|
||||
type: 'object',
|
||||
properties: {
|
||||
payment: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paymentController.getOne.bind(paymentController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create debt payment
|
||||
*/
|
||||
fastify.post(
|
||||
'/payments',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a new debt payment',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['accountId', 'amount', 'paymentDate'],
|
||||
properties: {
|
||||
accountId: {type: 'string', format: 'uuid'},
|
||||
amount: {type: 'number', minimum: 0.01},
|
||||
paymentDate: {type: 'string', format: 'date-time'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Debt payment created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
payment: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paymentController.create.bind(paymentController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete debt payment
|
||||
*/
|
||||
fastify.delete(
|
||||
'/payments/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete a debt payment',
|
||||
tags: ['Debts'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Debt payment deleted successfully',
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paymentController.delete.bind(paymentController)
|
||||
);
|
||||
}
|
||||
337
backend-api/src/routes/invoice.routes.ts
Normal file
337
backend-api/src/routes/invoice.routes.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {InvoiceController} from '../controllers/InvoiceController';
|
||||
import {InvoiceService} from '../services/InvoiceService';
|
||||
import {InvoiceRepository} from '../repositories/InvoiceRepository';
|
||||
import {ClientRepository} from '../repositories/ClientRepository';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
const invoiceRepository = new InvoiceRepository();
|
||||
const clientRepository = new ClientRepository();
|
||||
const invoiceService = new InvoiceService(invoiceRepository, clientRepository);
|
||||
const invoiceController = new InvoiceController(invoiceService);
|
||||
|
||||
export async function invoiceRoutes(fastify: FastifyInstance) {
|
||||
// Apply authentication to all routes
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
|
||||
/**
|
||||
* Get all invoices
|
||||
*/
|
||||
fastify.get(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all invoices for the authenticated user',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
clientId: {type: 'string', description: 'Filter by client ID'},
|
||||
status: {type: 'string', description: 'Filter by status'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of invoices',
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoices: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
invoiceNumber: {type: 'string'},
|
||||
status: {type: 'string'},
|
||||
issueDate: {type: 'string'},
|
||||
dueDate: {type: 'string'},
|
||||
subtotal: {type: 'number'},
|
||||
tax: {type: 'number'},
|
||||
total: {type: 'number'},
|
||||
notes: {type: 'string', nullable: true},
|
||||
terms: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.getAll.bind(invoiceController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get invoice statistics
|
||||
*/
|
||||
fastify.get(
|
||||
'/stats',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get invoice statistics (total, paid, outstanding, overdue)',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Invoice statistics',
|
||||
type: 'object',
|
||||
properties: {
|
||||
stats: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {type: 'number'},
|
||||
paid: {type: 'number'},
|
||||
outstanding: {type: 'number'},
|
||||
overdue: {type: 'number'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.getStats.bind(invoiceController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get overdue invoices
|
||||
*/
|
||||
fastify.get(
|
||||
'/overdue',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all overdue invoices',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of overdue invoices',
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoices: {type: 'array', items: {type: 'object'}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.getOverdue.bind(invoiceController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single invoice
|
||||
*/
|
||||
fastify.get(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get a single invoice by ID',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Invoice details',
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoice: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
invoiceNumber: {type: 'string'},
|
||||
status: {type: 'string'},
|
||||
issueDate: {type: 'string'},
|
||||
dueDate: {type: 'string'},
|
||||
subtotal: {type: 'number'},
|
||||
tax: {type: 'number'},
|
||||
total: {type: 'number'},
|
||||
notes: {type: 'string', nullable: true},
|
||||
terms: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.getOne.bind(invoiceController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create invoice
|
||||
*/
|
||||
fastify.post(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a new invoice',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['clientId', 'issueDate', 'dueDate', 'lineItems'],
|
||||
properties: {
|
||||
clientId: {type: 'string', format: 'uuid'},
|
||||
issueDate: {type: 'string', format: 'date-time'},
|
||||
dueDate: {type: 'string', format: 'date-time'},
|
||||
lineItems: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['description', 'quantity', 'unitPrice', 'amount'],
|
||||
properties: {
|
||||
description: {type: 'string', minLength: 1},
|
||||
quantity: {type: 'number', minimum: 1},
|
||||
unitPrice: {type: 'number', minimum: 0},
|
||||
amount: {type: 'number', minimum: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {type: 'string'},
|
||||
terms: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Invoice created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoice: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.create.bind(invoiceController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Update invoice
|
||||
*/
|
||||
fastify.put(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Update an invoice',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
issueDate: {type: 'string', format: 'date-time'},
|
||||
dueDate: {type: 'string', format: 'date-time'},
|
||||
lineItems: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['description', 'quantity', 'unitPrice', 'amount'],
|
||||
properties: {
|
||||
description: {type: 'string', minLength: 1},
|
||||
quantity: {type: 'number', minimum: 1},
|
||||
unitPrice: {type: 'number', minimum: 0},
|
||||
amount: {type: 'number', minimum: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {type: 'string'},
|
||||
terms: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Invoice updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoice: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.update.bind(invoiceController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Update invoice status
|
||||
*/
|
||||
fastify.patch(
|
||||
'/:id/status',
|
||||
{
|
||||
schema: {
|
||||
description: 'Update invoice status',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['status'],
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED'],
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Invoice status updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoice: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.updateStatus.bind(invoiceController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete invoice
|
||||
*/
|
||||
fastify.delete(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete an invoice',
|
||||
tags: ['Invoices'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Invoice deleted successfully',
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoiceController.delete.bind(invoiceController)
|
||||
);
|
||||
}
|
||||
263
backend-api/src/routes/liability.routes.ts
Normal file
263
backend-api/src/routes/liability.routes.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {LiabilityController} from '../controllers/LiabilityController';
|
||||
import {LiabilityService} from '../services/LiabilityService';
|
||||
import {LiabilityRepository} from '../repositories/LiabilityRepository';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
const liabilityRepository = new LiabilityRepository();
|
||||
const liabilityService = new LiabilityService(liabilityRepository);
|
||||
const liabilityController = new LiabilityController(liabilityService);
|
||||
|
||||
export async function liabilityRoutes(fastify: FastifyInstance) {
|
||||
// Apply authentication to all routes
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
|
||||
/**
|
||||
* Get all liabilities
|
||||
*/
|
||||
fastify.get(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all liabilities for the authenticated user',
|
||||
tags: ['Liabilities'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of liabilities',
|
||||
type: 'object',
|
||||
properties: {
|
||||
liabilities: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
name: {type: 'string'},
|
||||
type: {type: 'string'},
|
||||
currentBalance: {type: 'number'},
|
||||
interestRate: {type: 'number', nullable: true},
|
||||
minimumPayment: {type: 'number', nullable: true},
|
||||
dueDate: {type: 'string', nullable: true},
|
||||
creditor: {type: 'string', nullable: true},
|
||||
notes: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liabilityController.getAll.bind(liabilityController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get total liability value
|
||||
*/
|
||||
fastify.get(
|
||||
'/total',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get total value of all liabilities',
|
||||
tags: ['Liabilities'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Total liability value',
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalValue: {type: 'number'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liabilityController.getTotalValue.bind(liabilityController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get liabilities by type
|
||||
*/
|
||||
fastify.get(
|
||||
'/by-type',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get liabilities grouped by type',
|
||||
tags: ['Liabilities'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Liabilities grouped by type',
|
||||
type: 'object',
|
||||
properties: {
|
||||
liabilitiesByType: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'array',
|
||||
items: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liabilityController.getByType.bind(liabilityController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single liability
|
||||
*/
|
||||
fastify.get(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get a single liability by ID',
|
||||
tags: ['Liabilities'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Liability details',
|
||||
type: 'object',
|
||||
properties: {
|
||||
liability: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
name: {type: 'string'},
|
||||
type: {type: 'string'},
|
||||
currentBalance: {type: 'number'},
|
||||
interestRate: {type: 'number', nullable: true},
|
||||
minimumPayment: {type: 'number', nullable: true},
|
||||
dueDate: {type: 'string', nullable: true},
|
||||
creditor: {type: 'string', nullable: true},
|
||||
notes: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
updatedAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liabilityController.getOne.bind(liabilityController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create liability
|
||||
*/
|
||||
fastify.post(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a new liability',
|
||||
tags: ['Liabilities'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name', 'type', 'currentBalance'],
|
||||
properties: {
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
type: {type: 'string'},
|
||||
currentBalance: {type: 'number', minimum: 0},
|
||||
interestRate: {type: 'number', minimum: 0, maximum: 100},
|
||||
minimumPayment: {type: 'number', minimum: 0},
|
||||
dueDate: {type: 'string', format: 'date-time'},
|
||||
creditor: {type: 'string', maxLength: 255},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Liability created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
liability: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liabilityController.create.bind(liabilityController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Update liability
|
||||
*/
|
||||
fastify.put(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Update a liability',
|
||||
tags: ['Liabilities'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {type: 'string', minLength: 1, maxLength: 255},
|
||||
type: {type: 'string'},
|
||||
currentBalance: {type: 'number', minimum: 0},
|
||||
interestRate: {type: 'number', minimum: 0, maximum: 100},
|
||||
minimumPayment: {type: 'number', minimum: 0},
|
||||
dueDate: {type: 'string', format: 'date-time'},
|
||||
creditor: {type: 'string', maxLength: 255},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Liability updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
liability: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liabilityController.update.bind(liabilityController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete liability
|
||||
*/
|
||||
fastify.delete(
|
||||
'/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete a liability',
|
||||
tags: ['Liabilities'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Liability deleted successfully',
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liabilityController.delete.bind(liabilityController)
|
||||
);
|
||||
}
|
||||
279
backend-api/src/routes/networth.routes.ts
Normal file
279
backend-api/src/routes/networth.routes.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {FastifyInstance} from 'fastify';
|
||||
import {NetWorthController} from '../controllers/NetWorthController';
|
||||
import {NetWorthService} from '../services/NetWorthService';
|
||||
import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository';
|
||||
import {AssetRepository} from '../repositories/AssetRepository';
|
||||
import {LiabilityRepository} from '../repositories/LiabilityRepository';
|
||||
import {authenticate} from '../middleware/auth';
|
||||
|
||||
const snapshotRepository = new NetWorthSnapshotRepository();
|
||||
const assetRepository = new AssetRepository();
|
||||
const liabilityRepository = new LiabilityRepository();
|
||||
const netWorthService = new NetWorthService(snapshotRepository, assetRepository, liabilityRepository);
|
||||
const netWorthController = new NetWorthController(netWorthService);
|
||||
|
||||
export async function netWorthRoutes(fastify: FastifyInstance) {
|
||||
// Apply authentication to all routes
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
|
||||
/**
|
||||
* Get current net worth
|
||||
*/
|
||||
fastify.get(
|
||||
'/current',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get current net worth (calculated or from latest snapshot)',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Current net worth',
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalAssets: {type: 'number'},
|
||||
totalLiabilities: {type: 'number'},
|
||||
netWorth: {type: 'number'},
|
||||
asOf: {type: 'string'},
|
||||
isCalculated: {type: 'boolean'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.getCurrent.bind(netWorthController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all snapshots
|
||||
*/
|
||||
fastify.get(
|
||||
'/snapshots',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get all net worth snapshots',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of snapshots',
|
||||
type: 'object',
|
||||
properties: {
|
||||
snapshots: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
date: {type: 'string'},
|
||||
totalAssets: {type: 'number'},
|
||||
totalLiabilities: {type: 'number'},
|
||||
netWorth: {type: 'number'},
|
||||
notes: {type: 'string', nullable: true},
|
||||
createdAt: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.getAllSnapshots.bind(netWorthController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get snapshots by date range
|
||||
*/
|
||||
fastify.get(
|
||||
'/snapshots/range',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get snapshots within a date range',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['startDate', 'endDate'],
|
||||
properties: {
|
||||
startDate: {type: 'string', format: 'date-time'},
|
||||
endDate: {type: 'string', format: 'date-time'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Snapshots in date range',
|
||||
type: 'object',
|
||||
properties: {
|
||||
snapshots: {type: 'array', items: {type: 'object'}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.getByDateRange.bind(netWorthController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get growth statistics
|
||||
*/
|
||||
fastify.get(
|
||||
'/growth',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get net worth growth statistics',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {type: 'string', description: 'Number of periods to include (default: 12)'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Growth statistics',
|
||||
type: 'object',
|
||||
properties: {
|
||||
stats: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: {type: 'string'},
|
||||
netWorth: {type: 'number'},
|
||||
growthAmount: {type: 'number'},
|
||||
growthPercent: {type: 'number'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.getGrowthStats.bind(netWorthController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single snapshot
|
||||
*/
|
||||
fastify.get(
|
||||
'/snapshots/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Get a single snapshot by ID',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Snapshot details',
|
||||
type: 'object',
|
||||
properties: {
|
||||
snapshot: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.getOne.bind(netWorthController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create manual snapshot
|
||||
*/
|
||||
fastify.post(
|
||||
'/snapshots',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a new net worth snapshot manually',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['date', 'totalAssets', 'totalLiabilities', 'netWorth'],
|
||||
properties: {
|
||||
date: {type: 'string', format: 'date-time'},
|
||||
totalAssets: {type: 'number', minimum: 0},
|
||||
totalLiabilities: {type: 'number', minimum: 0},
|
||||
netWorth: {type: 'number'},
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Snapshot created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
snapshot: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.createSnapshot.bind(netWorthController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create snapshot from current data
|
||||
*/
|
||||
fastify.post(
|
||||
'/snapshots/record',
|
||||
{
|
||||
schema: {
|
||||
description: 'Create a snapshot from current assets and liabilities',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
notes: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
description: 'Snapshot created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
snapshot: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.createFromCurrent.bind(netWorthController)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete snapshot
|
||||
*/
|
||||
fastify.delete(
|
||||
'/snapshots/:id',
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete a snapshot',
|
||||
tags: ['Net Worth'],
|
||||
security: [{bearerAuth: []}],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string'},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Snapshot deleted successfully',
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
netWorthController.delete.bind(netWorthController)
|
||||
);
|
||||
}
|
||||
98
backend-api/src/server.ts
Normal file
98
backend-api/src/server.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import swagger from '@fastify/swagger';
|
||||
import swaggerUi from '@fastify/swagger-ui';
|
||||
import {env} from './config/env';
|
||||
import {errorHandler} from './middleware/errorHandler';
|
||||
import {authRoutes} from './routes/auth';
|
||||
import {assetRoutes} from './routes/assets';
|
||||
import {liabilityRoutes} from './routes/liability.routes';
|
||||
import {clientRoutes} from './routes/client.routes';
|
||||
import {invoiceRoutes} from './routes/invoice.routes';
|
||||
import {netWorthRoutes} from './routes/networth.routes';
|
||||
import {debtRoutes} from './routes/debt.routes';
|
||||
import {cashflowRoutes} from './routes/cashflow.routes';
|
||||
import {dashboardRoutes} from './routes/dashboard.routes';
|
||||
|
||||
/**
|
||||
* Create and configure Fastify server
|
||||
* Implements Single Responsibility: Server configuration
|
||||
*/
|
||||
export async function buildServer() {
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: env.NODE_ENV === 'development' ? 'info' : 'error',
|
||||
transport: env.NODE_ENV === 'development' ? {target: 'pino-pretty'} : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Register plugins
|
||||
await fastify.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: env.JWT_SECRET,
|
||||
sign: {
|
||||
expiresIn: env.JWT_EXPIRES_IN,
|
||||
},
|
||||
});
|
||||
|
||||
// Register Swagger for API documentation
|
||||
await fastify.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Personal Finances API',
|
||||
description: 'API for managing personal finances including assets, liabilities, invoices, and more',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: `http://localhost:${env.PORT}`,
|
||||
description: 'Development server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.register(swaggerUi, {
|
||||
routePrefix: '/docs',
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Register error handler
|
||||
fastify.setErrorHandler(errorHandler);
|
||||
|
||||
// Health check
|
||||
fastify.get('/health', async () => ({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Register routes
|
||||
await fastify.register(authRoutes, {prefix: '/api/auth'});
|
||||
await fastify.register(assetRoutes, {prefix: '/api/assets'});
|
||||
await fastify.register(liabilityRoutes, {prefix: '/api/liabilities'});
|
||||
await fastify.register(clientRoutes, {prefix: '/api/clients'});
|
||||
await fastify.register(invoiceRoutes, {prefix: '/api/invoices'});
|
||||
await fastify.register(netWorthRoutes, {prefix: '/api/net-worth'});
|
||||
await fastify.register(debtRoutes, {prefix: '/api/debts'});
|
||||
await fastify.register(cashflowRoutes, {prefix: '/api/cashflow'});
|
||||
await fastify.register(dashboardRoutes, {prefix: '/api/dashboard'});
|
||||
|
||||
return fastify;
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
38
backend-api/src/utils/errors.ts
Normal file
38
backend-api/src/utils/errors.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Custom error classes
|
||||
* Implements Open/Closed Principle: Extensible for new error types
|
||||
*/
|
||||
|
||||
export abstract class AppError extends Error {
|
||||
abstract statusCode: number;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
statusCode = 404;
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
statusCode = 401;
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
statusCode = 403;
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
statusCode = 409;
|
||||
}
|
||||
|
||||
export class InternalServerError extends AppError {
|
||||
statusCode = 500;
|
||||
}
|
||||
39
backend-api/src/utils/password.ts
Normal file
39
backend-api/src/utils/password.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* Password hashing utilities
|
||||
* Implements Single Responsibility: Only handles password operations
|
||||
*/
|
||||
export class PasswordService {
|
||||
private static readonly SALT_ROUNDS = 10;
|
||||
|
||||
static async hash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, this.SALT_ROUNDS);
|
||||
}
|
||||
|
||||
static async compare(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
static validate(password: string): {valid: boolean; errors: string[]} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters');
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user