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