Add backend API for personal finance management application

- Introduced a comprehensive backend API using TypeScript, Fastify, and PostgreSQL.
- Added essential files including architecture documentation, environment configuration, and Docker setup.
- Implemented RESTful routes for managing assets, liabilities, clients, invoices, and cashflow.
- Established a robust database schema with Prisma for data management.
- Integrated middleware for authentication and error handling.
- Created service and repository layers to adhere to SOLID principles and clean architecture.
- Included example environment variables for development, staging, and production setups.
This commit is contained in:
2025-12-07 12:59:09 -05:00
parent 9d493ba82f
commit cd93dcbfd2
70 changed files with 8649 additions and 6 deletions

View File

@@ -0,0 +1,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;
}
}

View 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,
};
}
}

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

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

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

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

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

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

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

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

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