diff --git a/backend-api/src/config/database.ts b/backend-api/src/config/database.ts index 7831e5b..bf45b87 100644 --- a/backend-api/src/config/database.ts +++ b/backend-api/src/config/database.ts @@ -12,9 +12,10 @@ class DatabaseConnection { public static getInstance(): PrismaClient { if (!DatabaseConnection.instance) { DatabaseConnection.instance = new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'] + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], }); } + return DatabaseConnection.instance; } diff --git a/backend-api/src/controllers/AuthController.ts b/backend-api/src/controllers/AuthController.ts index 44f2ee6..66c1a9b 100644 --- a/backend-api/src/controllers/AuthController.ts +++ b/backend-api/src/controllers/AuthController.ts @@ -55,7 +55,7 @@ export class AuthController { email: user.email }); - const {password: _, ...userWithoutPassword} = user; + const {password: _password, ...userWithoutPassword} = user; return reply.send({ user: userWithoutPassword, diff --git a/backend-api/src/controllers/CashflowController.ts b/backend-api/src/controllers/CashflowController.ts index c082256..96311ae 100644 --- a/backend-api/src/controllers/CashflowController.ts +++ b/backend-api/src/controllers/CashflowController.ts @@ -46,12 +46,14 @@ export class CashflowController { 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}); } @@ -59,6 +61,7 @@ export class CashflowController { const userId = getUserId(request); const {id} = request.params as {id: string}; const income = await this.cashflowService.getIncomeById(id, userId); + return reply.send({income}); } @@ -67,6 +70,7 @@ export class CashflowController { 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}); } @@ -74,12 +78,14 @@ export class CashflowController { 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}); } @@ -88,6 +94,7 @@ export class CashflowController { const userId = getUserId(request); const data = createExpenseSchema.parse(request.body); const expense = await this.cashflowService.createExpense(userId, data); + return reply.status(201).send({expense}); } @@ -97,10 +104,12 @@ export class CashflowController { 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}); } @@ -108,6 +117,7 @@ export class CashflowController { const userId = getUserId(request); const {id} = request.params as {id: string}; const expense = await this.cashflowService.getExpenseById(id, userId); + return reply.send({expense}); } @@ -116,6 +126,7 @@ export class CashflowController { 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}); } @@ -123,12 +134,14 @@ export class CashflowController { 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}); } @@ -137,6 +150,7 @@ export class CashflowController { const userId = getUserId(request); const data = createTransactionSchema.parse(request.body); const transaction = await this.cashflowService.createTransaction(userId, data); + return reply.status(201).send({transaction}); } @@ -150,15 +164,18 @@ export class CashflowController { 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}); } @@ -166,6 +183,7 @@ export class CashflowController { const userId = getUserId(request); const {id} = request.params as {id: string}; const transaction = await this.cashflowService.getTransactionById(id, userId); + return reply.send({transaction}); } @@ -173,6 +191,7 @@ export class CashflowController { const userId = getUserId(request); const {id} = request.params as {id: string}; await this.cashflowService.deleteTransaction(id, userId); + return reply.status(204).send(); } diff --git a/backend-api/src/controllers/ClientController.ts b/backend-api/src/controllers/ClientController.ts index 40b7287..7b13ed8 100644 --- a/backend-api/src/controllers/ClientController.ts +++ b/backend-api/src/controllers/ClientController.ts @@ -47,10 +47,12 @@ export class ClientController { 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}); } diff --git a/backend-api/src/controllers/DebtAccountController.ts b/backend-api/src/controllers/DebtAccountController.ts index a51393b..49dfb34 100644 --- a/backend-api/src/controllers/DebtAccountController.ts +++ b/backend-api/src/controllers/DebtAccountController.ts @@ -61,15 +61,18 @@ export class DebtAccountController { 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}); } diff --git a/backend-api/src/controllers/DebtCategoryController.ts b/backend-api/src/controllers/DebtCategoryController.ts index a7aaf30..312c8b8 100644 --- a/backend-api/src/controllers/DebtCategoryController.ts +++ b/backend-api/src/controllers/DebtCategoryController.ts @@ -49,10 +49,12 @@ export class DebtCategoryController { 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}); } diff --git a/backend-api/src/controllers/DebtPaymentController.ts b/backend-api/src/controllers/DebtPaymentController.ts index eb04b65..c532ddb 100644 --- a/backend-api/src/controllers/DebtPaymentController.ts +++ b/backend-api/src/controllers/DebtPaymentController.ts @@ -42,15 +42,18 @@ export class DebtPaymentController { 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}); } diff --git a/backend-api/src/middleware/auth.ts b/backend-api/src/middleware/auth.ts index 56c7bc4..805c95f 100644 --- a/backend-api/src/middleware/auth.ts +++ b/backend-api/src/middleware/auth.ts @@ -5,10 +5,10 @@ import {UnauthorizedError} from '../utils/errors'; * Authentication Middleware * Verifies JWT token and attaches user to request */ -export async function authenticate(request: FastifyRequest, reply: FastifyReply) { +export async function authenticate(request: FastifyRequest, _reply: FastifyReply) { try { await request.jwtVerify(); - } catch (err) { + } catch (_err) { throw new UnauthorizedError('Invalid or expired token'); } } @@ -20,5 +20,6 @@ export function getUserId(request: FastifyRequest): string { if (!request.user || !request.user.id) { throw new UnauthorizedError('User not authenticated'); } + return request.user.id; } diff --git a/backend-api/src/middleware/errorHandler.ts b/backend-api/src/middleware/errorHandler.ts index 8a80713..c9041ea 100644 --- a/backend-api/src/middleware/errorHandler.ts +++ b/backend-api/src/middleware/errorHandler.ts @@ -38,7 +38,7 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest, // Handle Prisma errors if (error.name === 'PrismaClientKnownRequestError') { - const prismaError = error as any; + const prismaError = error as FastifyError & {code?: string}; if (prismaError.code === 'P2002') { return reply.status(409).send({ error: 'ConflictError', diff --git a/backend-api/src/repositories/AssetRepository.ts b/backend-api/src/repositories/AssetRepository.ts index 105fc07..73c1d3c 100644 --- a/backend-api/src/repositories/AssetRepository.ts +++ b/backend-api/src/repositories/AssetRepository.ts @@ -16,7 +16,7 @@ export class AssetRepository { }); } - async findAllByUser(userId: string, filters?: Record): Promise { + async findAllByUser(userId: string, filters?: Record): Promise { return prisma.asset.findMany({ where: {userId, ...filters}, orderBy: {createdAt: 'desc'} @@ -43,6 +43,7 @@ export class AssetRepository { where: {userId}, _sum: {value: true} }); + return result._sum.value || 0; } } diff --git a/backend-api/src/repositories/CashflowRepository.ts b/backend-api/src/repositories/CashflowRepository.ts index 17e7d1b..99c4d4c 100644 --- a/backend-api/src/repositories/CashflowRepository.ts +++ b/backend-api/src/repositories/CashflowRepository.ts @@ -39,6 +39,7 @@ export class IncomeSourceRepository { where: {userId}, _sum: {amount: true} }); + return result._sum.amount || 0; } } @@ -79,15 +80,18 @@ export class ExpenseRepository { where: {userId}, _sum: {amount: true} }); + return result._sum.amount || 0; } async getByCategory(userId: string): Promise> { 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 diff --git a/backend-api/src/repositories/ClientRepository.ts b/backend-api/src/repositories/ClientRepository.ts index 40ba1c1..69ca69b 100644 --- a/backend-api/src/repositories/ClientRepository.ts +++ b/backend-api/src/repositories/ClientRepository.ts @@ -3,6 +3,17 @@ import {DatabaseConnection} from '../config/database'; const prisma = DatabaseConnection.getInstance(); +export interface ClientStats { + totalInvoices: number; + paidInvoices: number; + totalRevenue: number; + outstandingAmount: number; +} + +export interface ClientWithStats extends Client { + stats: ClientStats; +} + /** * Repository for Client data access * Implements Single Responsibility Principle - handles only database operations @@ -97,7 +108,7 @@ export class ClientRepository { /** * Get clients with their invoice statistics */ - async getWithStats(userId: string): Promise { + async getWithStats(userId: string): Promise { const clients = await prisma.client.findMany({ where: {userId}, include: { diff --git a/backend-api/src/repositories/DebtAccountRepository.ts b/backend-api/src/repositories/DebtAccountRepository.ts index 4b8d945..4978bfb 100644 --- a/backend-api/src/repositories/DebtAccountRepository.ts +++ b/backend-api/src/repositories/DebtAccountRepository.ts @@ -3,6 +3,17 @@ import {DatabaseConnection} from '../config/database'; const prisma = DatabaseConnection.getInstance(); +export interface DebtAccountStats { + totalPaid: number; + numberOfPayments: number; + lastPaymentDate: Date | null; + lastPaymentAmount: number | null; +} + +export interface DebtAccountWithStats extends DebtAccount { + stats: DebtAccountStats; +} + /** * Repository for DebtAccount data access * Implements Single Responsibility Principle - handles only database operations @@ -101,7 +112,7 @@ export class DebtAccountRepository { /** * Get accounts with payment statistics */ - async getWithStats(userId: string): Promise { + async getWithStats(userId: string): Promise { const accounts = await prisma.debtAccount.findMany({ where: {userId}, include: { diff --git a/backend-api/src/repositories/DebtCategoryRepository.ts b/backend-api/src/repositories/DebtCategoryRepository.ts index ab9de57..fc8f597 100644 --- a/backend-api/src/repositories/DebtCategoryRepository.ts +++ b/backend-api/src/repositories/DebtCategoryRepository.ts @@ -4,6 +4,16 @@ import {IUserScopedRepository} from './interfaces/IRepository'; const prisma = DatabaseConnection.getInstance(); +export interface DebtCategoryStats { + totalAccounts: number; + totalDebt: number; + totalPayments: number; +} + +export interface DebtCategoryWithStats extends DebtCategory { + stats: DebtCategoryStats; +} + /** * Repository for DebtCategory data access * Implements Single Responsibility Principle - handles only database operations @@ -91,7 +101,7 @@ export class DebtCategoryRepository implements IUserScopedRepository { + async getWithStats(userId: string): Promise { const categories = await this.findAllByUser(userId); return Promise.all( diff --git a/backend-api/src/repositories/InvoiceRepository.ts b/backend-api/src/repositories/InvoiceRepository.ts index 6218686..00e5105 100644 --- a/backend-api/src/repositories/InvoiceRepository.ts +++ b/backend-api/src/repositories/InvoiceRepository.ts @@ -61,6 +61,7 @@ export class InvoiceRepository implements IUserScopedRepository { ...(excludeId && {id: {not: excludeId}}) } }); + return count > 0; } @@ -72,6 +73,7 @@ export class InvoiceRepository implements IUserScopedRepository { invoiceNumber: {startsWith: `INV-${year}-`} } }); + return `INV-${year}-${String(count + 1).padStart(3, '0')}`; } } diff --git a/backend-api/src/repositories/LiabilityRepository.ts b/backend-api/src/repositories/LiabilityRepository.ts index da8c336..de8ea82 100644 --- a/backend-api/src/repositories/LiabilityRepository.ts +++ b/backend-api/src/repositories/LiabilityRepository.ts @@ -73,6 +73,7 @@ export class LiabilityRepository { acc[type] = []; } acc[type].push(liability); + return acc; }, {} as Record diff --git a/backend-api/src/repositories/NetWorthSnapshotRepository.ts b/backend-api/src/repositories/NetWorthSnapshotRepository.ts index c758a71..10b5c7d 100644 --- a/backend-api/src/repositories/NetWorthSnapshotRepository.ts +++ b/backend-api/src/repositories/NetWorthSnapshotRepository.ts @@ -4,6 +4,13 @@ import {IUserScopedRepository} from './interfaces/IRepository'; const prisma = DatabaseConnection.getInstance(); +export interface GrowthStats { + date: Date; + netWorth: number; + growthAmount: number; + growthPercent: number; +} + /** * Repository for NetWorthSnapshot data access * Implements Single Responsibility Principle - handles only database operations @@ -84,7 +91,7 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository { + async getGrowthStats(userId: string, limit: number = 12): Promise { const snapshots = await prisma.netWorthSnapshot.findMany({ where: {userId}, orderBy: {date: 'desc'}, diff --git a/backend-api/src/repositories/UserRepository.ts b/backend-api/src/repositories/UserRepository.ts index d2ede5a..6007e7d 100644 --- a/backend-api/src/repositories/UserRepository.ts +++ b/backend-api/src/repositories/UserRepository.ts @@ -46,6 +46,7 @@ export class UserRepository implements IRepository { async emailExists(email: string): Promise { const count = await prisma.user.count({where: {email}}); + return count > 0; } } diff --git a/backend-api/src/services/AssetService.ts b/backend-api/src/services/AssetService.ts index e5a2fe9..7a48ce2 100644 --- a/backend-api/src/services/AssetService.ts +++ b/backend-api/src/services/AssetService.ts @@ -34,6 +34,7 @@ export class AssetService { if (!asset) { throw new NotFoundError('Asset not found'); } + return asset; } @@ -80,6 +81,7 @@ export class AssetService { async getByType(userId: string): Promise> { const assets = await this.assetRepository.findAllByUser(userId); + return assets.reduce( (acc, asset) => { const type = asset.type; @@ -87,6 +89,7 @@ export class AssetService { acc[type] = []; } acc[type].push(asset); + return acc; }, {} as Record diff --git a/backend-api/src/services/AuthService.ts b/backend-api/src/services/AuthService.ts index e6ce55f..62d64e8 100644 --- a/backend-api/src/services/AuthService.ts +++ b/backend-api/src/services/AuthService.ts @@ -40,7 +40,8 @@ export class AuthService { await this.debtCategoryService.createDefaultCategories(user.id); // Return user without password - const {password: _, ...userWithoutPassword} = user; + const {password: _password, ...userWithoutPassword} = user; + return userWithoutPassword; } @@ -62,7 +63,8 @@ export class AuthService { const user = await this.userRepository.findById(id); if (!user) return null; - const {password: _, ...userWithoutPassword} = user; + const {password: _password, ...userWithoutPassword} = user; + return userWithoutPassword; } } diff --git a/backend-api/src/services/CashflowService.ts b/backend-api/src/services/CashflowService.ts index c17d579..756c90f 100644 --- a/backend-api/src/services/CashflowService.ts +++ b/backend-api/src/services/CashflowService.ts @@ -55,6 +55,7 @@ export class CashflowService { 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; } @@ -63,6 +64,7 @@ export class CashflowService { if (data.amount !== undefined && data.amount <= 0) { throw new ValidationError('Amount must be greater than 0'); } + return this.incomeRepository.update(id, data); } @@ -93,6 +95,7 @@ export class CashflowService { 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; } @@ -101,6 +104,7 @@ export class CashflowService { if (data.amount !== undefined && data.amount <= 0) { throw new ValidationError('Amount must be greater than 0'); } + return this.expenseRepository.update(id, data); } @@ -136,6 +140,7 @@ export class CashflowService { 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; } diff --git a/backend-api/src/services/ClientService.ts b/backend-api/src/services/ClientService.ts index 55f5f14..b94b11b 100644 --- a/backend-api/src/services/ClientService.ts +++ b/backend-api/src/services/ClientService.ts @@ -1,5 +1,5 @@ import {Client} from '@prisma/client'; -import {ClientRepository} from '../repositories/ClientRepository'; +import {ClientRepository, ClientWithStats} from '../repositories/ClientRepository'; import {NotFoundError, ValidationError, ForbiddenError, ConflictError} from '../utils/errors'; export interface CreateClientDTO { @@ -60,7 +60,7 @@ export class ClientService { /** * Get clients with statistics */ - async getWithStats(userId: string): Promise { + async getWithStats(userId: string): Promise { return this.clientRepository.getWithStats(userId); } @@ -107,7 +107,7 @@ export class ClientService { */ async delete(id: string, userId: string): Promise { // Verify ownership - const client = await this.getById(id, userId); + 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 diff --git a/backend-api/src/services/DebtAccountService.ts b/backend-api/src/services/DebtAccountService.ts index 9571887..32d0b3d 100644 --- a/backend-api/src/services/DebtAccountService.ts +++ b/backend-api/src/services/DebtAccountService.ts @@ -1,5 +1,5 @@ import {DebtAccount} from '@prisma/client'; -import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; +import {DebtAccountRepository, DebtAccountWithStats} from '../repositories/DebtAccountRepository'; import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository'; import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; @@ -79,7 +79,7 @@ export class DebtAccountService { /** * Get debt accounts with statistics */ - async getWithStats(userId: string): Promise { + async getWithStats(userId: string): Promise { return this.accountRepository.getWithStats(userId); } diff --git a/backend-api/src/services/DebtCategoryService.ts b/backend-api/src/services/DebtCategoryService.ts index 1f8f15c..2cdee1e 100644 --- a/backend-api/src/services/DebtCategoryService.ts +++ b/backend-api/src/services/DebtCategoryService.ts @@ -1,5 +1,5 @@ import {DebtCategory} from '@prisma/client'; -import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository'; +import {DebtCategoryRepository, DebtCategoryWithStats} from '../repositories/DebtCategoryRepository'; import {NotFoundError, ValidationError, ForbiddenError, ConflictError} from '../utils/errors'; export interface CreateDebtCategoryDTO { @@ -84,7 +84,7 @@ export class DebtCategoryService { /** * Get categories with statistics */ - async getWithStats(userId: string): Promise { + async getWithStats(userId: string): Promise { return this.categoryRepository.getWithStats(userId); } @@ -128,7 +128,7 @@ export class DebtCategoryService { * Delete a category */ async delete(id: string, userId: string): Promise { - const category = await this.getById(id, userId); + await this.getById(id, userId); // Check if category has accounts // Note: Cascade delete will remove all accounts and payments diff --git a/backend-api/src/services/InvoiceService.ts b/backend-api/src/services/InvoiceService.ts index 8123f89..2369838 100644 --- a/backend-api/src/services/InvoiceService.ts +++ b/backend-api/src/services/InvoiceService.ts @@ -59,6 +59,7 @@ export class InvoiceService { if (!invoice) { throw new NotFoundError('Invoice not found'); } + return invoice as unknown as Invoice; } @@ -193,6 +194,7 @@ export class InvoiceService { async getOverdueInvoices(userId: string): Promise { const invoices = await this.invoiceRepository.findAllByUser(userId, {status: 'overdue'}); + return invoices as unknown as Invoice[]; } diff --git a/backend-api/src/services/NetWorthService.ts b/backend-api/src/services/NetWorthService.ts index 4cebfe8..ba307ad 100644 --- a/backend-api/src/services/NetWorthService.ts +++ b/backend-api/src/services/NetWorthService.ts @@ -1,5 +1,5 @@ import {NetWorthSnapshot} from '@prisma/client'; -import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository'; +import {NetWorthSnapshotRepository, GrowthStats} from '../repositories/NetWorthSnapshotRepository'; import {AssetRepository} from '../repositories/AssetRepository'; import {LiabilityRepository} from '../repositories/LiabilityRepository'; import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; @@ -154,7 +154,7 @@ export class NetWorthService { /** * Get growth statistics */ - async getGrowthStats(userId: string, limit?: number): Promise { + async getGrowthStats(userId: string, limit?: number): Promise { return this.snapshotRepository.getGrowthStats(userId, limit); } diff --git a/eslint.config.js b/eslint.config.js index ea9bdbf..b3047df 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,8 @@ import {defineConfig, globalIgnores} from 'eslint/config'; // Shared rules for all TypeScript files const sharedRules = { - 'padding-line-between-statements': ['error', {blankLine: 'always', prev: '*', next: 'return'}] + 'padding-line-between-statements': ['error', {blankLine: 'always', prev: '*', next: 'return'}], + '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_'}] }; export default defineConfig([ diff --git a/frontend-web/src/components/dialogs/AddAssetDialog.tsx b/frontend-web/src/components/dialogs/AddAssetDialog.tsx index 40766de..a1e3b4b 100644 --- a/frontend-web/src/components/dialogs/AddAssetDialog.tsx +++ b/frontend-web/src/components/dialogs/AddAssetDialog.tsx @@ -35,6 +35,7 @@ export default function AddAssetDialog({open, onOpenChange}: Props) { } setErrors(newErrors); + return isValid; }; diff --git a/frontend-web/src/components/dialogs/AddInvoiceDialog.tsx b/frontend-web/src/components/dialogs/AddInvoiceDialog.tsx index fee82e7..57f609f 100644 --- a/frontend-web/src/components/dialogs/AddInvoiceDialog.tsx +++ b/frontend-web/src/components/dialogs/AddInvoiceDialog.tsx @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useState, useEffect} from 'react'; import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Button} from '@/components/ui/button'; @@ -11,6 +11,10 @@ interface Props { onOpenChange: (open: boolean) => void; } +function getDefaultDueDate(): string { + return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; +} + export default function AddInvoiceDialog({open, onOpenChange}: Props) { const dispatch = useAppDispatch(); const {clients} = useAppSelector(state => state.invoices); @@ -18,9 +22,17 @@ export default function AddInvoiceDialog({open, onOpenChange}: Props) { clientId: '', description: '', amount: '', - dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + dueDate: getDefaultDueDate() }); + // Reset form with fresh due date when dialog opens + useEffect(() => { + if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog + setForm(prev => ({...prev, dueDate: getDefaultDueDate()})); + } + }, [open]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const now = new Date().toISOString(); @@ -52,7 +64,7 @@ export default function AddInvoiceDialog({open, onOpenChange}: Props) { }) ); onOpenChange(false); - setForm({clientId: '', description: '', amount: '', dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}); + setForm({clientId: '', description: '', amount: '', dueDate: getDefaultDueDate()}); }; return ( diff --git a/frontend-web/src/components/dialogs/EditAssetDialog.tsx b/frontend-web/src/components/dialogs/EditAssetDialog.tsx index 6be6307..7f5f612 100644 --- a/frontend-web/src/components/dialogs/EditAssetDialog.tsx +++ b/frontend-web/src/components/dialogs/EditAssetDialog.tsx @@ -20,8 +20,10 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) { const [form, setForm] = useState({name: '', type: '', value: ''}); const [errors, setErrors] = useState({name: '', value: ''}); + // Sync form state when asset changes - intentional pattern for controlled form dialogs useEffect(() => { if (asset) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog setForm({ name: asset.name, type: asset.type, @@ -47,6 +49,7 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) { } setErrors(newErrors); + return isValid; }; diff --git a/frontend-web/src/components/dialogs/EditClientDialog.tsx b/frontend-web/src/components/dialogs/EditClientDialog.tsx index 3f73f4c..fdd682b 100644 --- a/frontend-web/src/components/dialogs/EditClientDialog.tsx +++ b/frontend-web/src/components/dialogs/EditClientDialog.tsx @@ -24,8 +24,10 @@ export default function EditClientDialog({open, onOpenChange, client}: Props) { }); const [errors, setErrors] = useState({name: '', email: '', phone: ''}); + // Sync form state when client changes - intentional pattern for controlled form dialogs useEffect(() => { if (client) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog setForm({ name: client.name, email: client.email, @@ -58,6 +60,7 @@ export default function EditClientDialog({open, onOpenChange, client}: Props) { } setErrors(newErrors); + return isValid; }; diff --git a/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx b/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx index a914971..8aec0dd 100644 --- a/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx +++ b/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx @@ -20,8 +20,10 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro const [form, setForm] = useState({name: '', type: '', balance: ''}); const [errors, setErrors] = useState({name: '', balance: ''}); + // Sync form state when liability changes - intentional pattern for controlled form dialogs useEffect(() => { if (liability) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog setForm({ name: liability.name, type: liability.type, @@ -47,6 +49,7 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro } setErrors(newErrors); + return isValid; }; diff --git a/frontend-web/src/components/dialogs/InvoiceDetailsDialog.tsx b/frontend-web/src/components/dialogs/InvoiceDetailsDialog.tsx index eb3f284..3abd32c 100644 --- a/frontend-web/src/components/dialogs/InvoiceDetailsDialog.tsx +++ b/frontend-web/src/components/dialogs/InvoiceDetailsDialog.tsx @@ -25,8 +25,10 @@ export default function InvoiceDetailsDialog({open, onOpenChange, invoice, clien const dispatch = useAppDispatch(); const [selectedStatus, setSelectedStatus] = useState('draft'); + // Sync status when invoice changes - intentional pattern for controlled form dialogs useEffect(() => { if (invoice) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog setSelectedStatus(invoice.status); } }, [invoice]); diff --git a/frontend-web/src/components/dialogs/LoginDialog.tsx b/frontend-web/src/components/dialogs/LoginDialog.tsx index e1a4de5..38bd6be 100644 --- a/frontend-web/src/components/dialogs/LoginDialog.tsx +++ b/frontend-web/src/components/dialogs/LoginDialog.tsx @@ -27,6 +27,7 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop if (!form.email || !form.password) { setError('Please enter your email and password'); + return; } @@ -41,8 +42,9 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop onOpenChange(false); setForm({email: '', password: ''}); - } catch (err: any) { - setError(err || 'Login failed. Please check your credentials.'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message || 'Login failed. Please check your credentials.'); } finally { setIsLoading(false); } diff --git a/frontend-web/src/components/dialogs/SignUpDialog.tsx b/frontend-web/src/components/dialogs/SignUpDialog.tsx index 25745ca..d14093a 100644 --- a/frontend-web/src/components/dialogs/SignUpDialog.tsx +++ b/frontend-web/src/components/dialogs/SignUpDialog.tsx @@ -29,11 +29,13 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop if (form.password !== form.confirmPassword) { setError('Passwords do not match'); + return; } if (form.password.length < 8) { setError('Password must be at least 8 characters'); + return; } @@ -49,8 +51,9 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop onOpenChange(false); setForm({name: '', email: '', password: '', confirmPassword: ''}); - } catch (err: any) { - setError(err || 'Registration failed. Please try again.'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message || 'Registration failed. Please try again.'); } finally { setIsLoading(false); } diff --git a/frontend-web/src/components/ui/button.tsx b/frontend-web/src/components/ui/button.tsx index 808a8e0..e511aaf 100644 --- a/frontend-web/src/components/ui/button.tsx +++ b/frontend-web/src/components/ui/button.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from 'react'; import {Slot} from '@radix-ui/react-slot'; import {cva, type VariantProps} from 'class-variance-authority'; diff --git a/frontend-web/src/lib/api/auth.service.ts b/frontend-web/src/lib/api/auth.service.ts index 78ef059..b9612e2 100644 --- a/frontend-web/src/lib/api/auth.service.ts +++ b/frontend-web/src/lib/api/auth.service.ts @@ -37,6 +37,7 @@ export const authService = { const response = await apiClient.post('/register', data); tokenStorage.setToken(response.token); tokenStorage.setUser(JSON.stringify(response.user)); + return response; }, @@ -44,6 +45,7 @@ export const authService = { const response = await apiClient.post('/login', data); tokenStorage.setToken(response.token); tokenStorage.setUser(JSON.stringify(response.user)); + return response; }, diff --git a/frontend-web/src/lib/api/client.ts b/frontend-web/src/lib/api/client.ts index 0a8f548..2a160a7 100644 --- a/frontend-web/src/lib/api/client.ts +++ b/frontend-web/src/lib/api/client.ts @@ -51,6 +51,7 @@ class ApiClient { error: response.statusText, } as ApiError; } + return {} as T; } diff --git a/frontend-web/src/lib/calculations.ts b/frontend-web/src/lib/calculations.ts index b6c3a05..a90a10c 100644 --- a/frontend-web/src/lib/calculations.ts +++ b/frontend-web/src/lib/calculations.ts @@ -32,6 +32,7 @@ export const calculateYTDGrowth = (snapshots: NetWorthSnapshot[]): number => { const currentValue = ytdSnapshots[ytdSnapshots.length - 1].netWorth; if (startValue === 0) return 0; + return ((currentValue - startValue) / Math.abs(startValue)) * 100; } @@ -39,6 +40,7 @@ export const calculateYTDGrowth = (snapshots: NetWorthSnapshot[]): number => { const currentValue = ytdSnapshots[ytdSnapshots.length - 1].netWorth; if (startValue === 0) return 0; + return ((currentValue - startValue) / Math.abs(startValue)) * 100; }; @@ -50,10 +52,12 @@ export const calculateAllTimeGrowth = (snapshots: NetWorthSnapshot[]): number => const last = sorted[sorted.length - 1]; if (first.netWorth === 0) return 0; + return ((last.netWorth - first.netWorth) / Math.abs(first.netWorth)) * 100; }; export const calculateSavingsRate = (totalIncome: number, totalExpenses: number): number => { if (totalIncome === 0) return 0; + return ((totalIncome - totalExpenses) / totalIncome) * 100; }; diff --git a/frontend-web/src/lib/formatters.ts b/frontend-web/src/lib/formatters.ts index 2d9f207..219f168 100644 --- a/frontend-web/src/lib/formatters.ts +++ b/frontend-web/src/lib/formatters.ts @@ -17,6 +17,7 @@ export const formatCurrencyCompact = (value: number): string => { if (Math.abs(value) >= 1000) { return `$${(value / 1000).toFixed(0)}k`; } + return formatCurrency(value); }; diff --git a/frontend-web/src/lib/validation.ts b/frontend-web/src/lib/validation.ts index c3cc47a..d119624 100644 --- a/frontend-web/src/lib/validation.ts +++ b/frontend-web/src/lib/validation.ts @@ -13,24 +13,28 @@ export const sanitizeString = (input: string): string => { export const validateEmail = (email: string): boolean => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); }; export const validatePhone = (phone: string): boolean => { // Accepts various phone formats - const phoneRegex = /^[\d\s\-\(\)\+]+$/; + const phoneRegex = /^[\d\s\-()+]+$/; + return phoneRegex.test(phone) && phone.replace(/\D/g, '').length >= 10; }; export const validateNumber = (value: string): number | null => { const parsed = parseFloat(value); if (isNaN(parsed)) return null; + return parsed; }; export const validatePositiveNumber = (value: string): number | null => { const num = validateNumber(value); if (num === null || num < 0) return null; + return num; }; @@ -42,6 +46,7 @@ export const validateInvoiceNumber = (invoiceNumber: string, existingNumbers: st if (!validateRequired(invoiceNumber)) return false; // Check uniqueness const sanitized = sanitizeString(invoiceNumber); + return !existingNumbers.some(num => num === sanitized); }; diff --git a/frontend-web/src/pages/CashflowPage.tsx b/frontend-web/src/pages/CashflowPage.tsx index 1449c86..967bdc0 100644 --- a/frontend-web/src/pages/CashflowPage.tsx +++ b/frontend-web/src/pages/CashflowPage.tsx @@ -42,6 +42,7 @@ export default function CashflowPage() { (acc, e) => { const monthly = getMonthlyAmount(e.amount, e.frequency); acc[e.category] = (acc[e.category] || 0) + monthly; + return acc; }, {} as Record @@ -115,6 +116,7 @@ export default function CashflowPage() {
{sortedCategories.map(([category, amount]) => { const pct = (amount / monthlyExpenses) * 100; + return (
{category}
diff --git a/frontend-web/src/pages/ClientsPage.tsx b/frontend-web/src/pages/ClientsPage.tsx index f226341..9cb5dfd 100644 --- a/frontend-web/src/pages/ClientsPage.tsx +++ b/frontend-web/src/pages/ClientsPage.tsx @@ -16,6 +16,7 @@ export default function ClientsPage() { const clientInvoices = invoices.filter(i => i.clientId === clientId); const totalBilled = clientInvoices.reduce((sum, i) => sum + i.total, 0); const outstanding = clientInvoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((sum, i) => sum + i.total, 0); + return {totalBilled, outstanding, count: clientInvoices.length}; }; @@ -54,6 +55,7 @@ export default function ClientsPage() {
{clients.map(client => { const stats = getClientStats(client.id); + return ( handleEditClient(client)}> diff --git a/frontend-web/src/pages/DebtsPage.tsx b/frontend-web/src/pages/DebtsPage.tsx index b137fe7..976cbb3 100644 --- a/frontend-web/src/pages/DebtsPage.tsx +++ b/frontend-web/src/pages/DebtsPage.tsx @@ -114,6 +114,7 @@ export default function DebtsPage() { {accounts.map(account => { const category = getCategoryById(account.categoryId); const progress = getProgress(account); + return ( diff --git a/frontend-web/src/pages/NetWorthPage.tsx b/frontend-web/src/pages/NetWorthPage.tsx index c22f9f1..f0fc993 100644 --- a/frontend-web/src/pages/NetWorthPage.tsx +++ b/frontend-web/src/pages/NetWorthPage.tsx @@ -192,6 +192,7 @@ export default function NetWorthPage() { const total = assets.filter(a => a.type === type).reduce((s, a) => s + a.value, 0); const pct = totalAssets > 0 ? (total / totalAssets) * 100 : 0; if (total === 0) return null; + return (
diff --git a/frontend-web/src/store/slices/cashflowSlice.ts b/frontend-web/src/store/slices/cashflowSlice.ts index fbe5889..2e17e53 100644 --- a/frontend-web/src/store/slices/cashflowSlice.ts +++ b/frontend-web/src/store/slices/cashflowSlice.ts @@ -95,27 +95,36 @@ const mapApiTransactionToTransaction = (apiTransaction: ApiTransaction): Transac export const fetchIncomeSources = createAsyncThunk('cashflow/fetchIncomeSources', async (_, {rejectWithValue}) => { try { const response = await incomeService.getAll(); + return response.incomeSources.map(mapApiIncomeToIncome); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to fetch income sources'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch income sources'; + + return rejectWithValue(message); } }); export const fetchExpenses = createAsyncThunk('cashflow/fetchExpenses', async (_, {rejectWithValue}) => { try { const response = await expenseService.getAll(); + return response.expenses.map(mapApiExpenseToExpense); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to fetch expenses'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch expenses'; + + return rejectWithValue(message); } }); export const fetchTransactions = createAsyncThunk('cashflow/fetchTransactions', async (_, {rejectWithValue}) => { try { const response = await transactionService.getAll(); + return response.transactions.map(mapApiTransactionToTransaction); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to fetch transactions'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch transactions'; + + return rejectWithValue(message); } }); diff --git a/frontend-web/src/store/slices/netWorthSlice.ts b/frontend-web/src/store/slices/netWorthSlice.ts index df506c6..cd20d89 100644 --- a/frontend-web/src/store/slices/netWorthSlice.ts +++ b/frontend-web/src/store/slices/netWorthSlice.ts @@ -72,36 +72,48 @@ const mapApiLiabilityToLiability = (apiLiability: ApiLiability): Liability => ({ export const fetchAssets = createAsyncThunk('netWorth/fetchAssets', async (_, {rejectWithValue}) => { try { const response = await assetService.getAll(); + return response.assets.map(mapApiAssetToAsset); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to fetch assets'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch assets'; + + return rejectWithValue(message); } }); export const createAsset = createAsyncThunk('netWorth/createAsset', async (data: CreateAssetRequest, {rejectWithValue}) => { try { const response = await assetService.create(data); + return mapApiAssetToAsset(response.asset); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to create asset'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create asset'; + + return rejectWithValue(message); } }); export const updateAsset = createAsyncThunk('netWorth/updateAsset', async ({id, data}: {id: string; data: UpdateAssetRequest}, {rejectWithValue}) => { try { const response = await assetService.update(id, data); + return mapApiAssetToAsset(response.asset); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to update asset'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update asset'; + + return rejectWithValue(message); } }); export const deleteAsset = createAsyncThunk('netWorth/deleteAsset', async (id: string, {rejectWithValue}) => { try { await assetService.delete(id); + return id; - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to delete asset'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete asset'; + + return rejectWithValue(message); } }); @@ -109,18 +121,24 @@ export const deleteAsset = createAsyncThunk('netWorth/deleteAsset', async (id: s export const fetchLiabilities = createAsyncThunk('netWorth/fetchLiabilities', async (_, {rejectWithValue}) => { try { const response = await liabilityService.getAll(); + return response.liabilities.map(mapApiLiabilityToLiability); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to fetch liabilities'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch liabilities'; + + return rejectWithValue(message); } }); export const createLiability = createAsyncThunk('netWorth/createLiability', async (data: CreateLiabilityRequest, {rejectWithValue}) => { try { const response = await liabilityService.create(data); + return mapApiLiabilityToLiability(response.liability); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to create liability'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create liability'; + + return rejectWithValue(message); } }); @@ -129,9 +147,12 @@ export const updateLiability = createAsyncThunk( async ({id, data}: {id: string; data: UpdateLiabilityRequest}, {rejectWithValue}) => { try { const response = await liabilityService.update(id, data); + return mapApiLiabilityToLiability(response.liability); - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to update liability'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update liability'; + + return rejectWithValue(message); } } ); @@ -139,9 +160,12 @@ export const updateLiability = createAsyncThunk( export const deleteLiability = createAsyncThunk('netWorth/deleteLiability', async (id: string, {rejectWithValue}) => { try { await liabilityService.delete(id); + return id; - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to delete liability'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete liability'; + + return rejectWithValue(message); } }); @@ -149,9 +173,12 @@ export const deleteLiability = createAsyncThunk('netWorth/deleteLiability', asyn export const fetchSnapshots = createAsyncThunk('netWorth/fetchSnapshots', async (_, {rejectWithValue}) => { try { const response = await snapshotService.getAll(); + return response.snapshots; - } catch (error: any) { - return rejectWithValue(error.message || 'Failed to fetch snapshots'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch snapshots'; + + return rejectWithValue(message); } }); diff --git a/frontend-web/src/store/slices/userSlice.ts b/frontend-web/src/store/slices/userSlice.ts index 3cb0202..1c49485 100644 --- a/frontend-web/src/store/slices/userSlice.ts +++ b/frontend-web/src/store/slices/userSlice.ts @@ -25,18 +25,24 @@ const initialState: UserState = { export const registerUser = createAsyncThunk('user/register', async (data: RegisterRequest, {rejectWithValue}) => { try { const response = await authService.register(data); + return response.user; - } catch (error: any) { - return rejectWithValue(error.message || 'Registration failed'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Registration failed'; + + return rejectWithValue(message); } }); export const loginUser = createAsyncThunk('user/login', async (data: LoginRequest, {rejectWithValue}) => { try { const response = await authService.login(data); + return response.user; - } catch (error: any) { - return rejectWithValue(error.message || 'Login failed'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Login failed'; + + return rejectWithValue(message); } }); @@ -48,10 +54,13 @@ export const loadUserFromStorage = createAsyncThunk('user/loadFromStorage', asyn } // Verify token is still valid by fetching profile await authService.getProfile(); + return user; - } catch (error: any) { + } catch (error) { authService.logout(); - return rejectWithValue(error.message || 'Session expired'); + const message = error instanceof Error ? error.message : 'Session expired'; + + return rejectWithValue(message); } });