From 40210c454e8f4343ba8d09e6a67036ed018ef361 Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Thu, 11 Dec 2025 02:11:43 -0500 Subject: [PATCH] Add lock files for package management and update architecture documentation - Introduced bun.lock and package-lock.json to manage dependencies for the project. - Enhanced backend API architecture documentation with additional security and documentation guidelines. - Made minor formatting adjustments across various files for consistency and clarity. --- backend-api/ARCHITECTURE.md | 46 ++- backend-api/CLAUDE.md | 2 +- backend-api/index.ts | 2 +- backend-api/src/config/database.ts | 2 +- backend-api/src/config/env.ts | 2 +- .../src/controllers/AssetController.ts | 9 +- backend-api/src/controllers/AuthController.ts | 12 +- .../src/controllers/CashflowController.ts | 23 +- .../src/controllers/ClientController.ts | 4 +- .../src/controllers/DebtAccountController.ts | 14 +- .../src/controllers/DebtCategoryController.ts | 10 +- .../src/controllers/DebtPaymentController.ts | 8 +- .../src/controllers/InvoiceController.ts | 20 +- .../src/controllers/LiabilityController.ts | 14 +- .../src/controllers/NetWorthController.ts | 17 +- backend-api/src/index.ts | 2 +- backend-api/src/middleware/errorHandler.ts | 12 +- .../src/repositories/AssetRepository.ts | 8 +- .../src/repositories/CashflowRepository.ts | 45 +- .../src/repositories/ClientRepository.ts | 54 ++- .../src/repositories/DebtAccountRepository.ts | 48 +-- .../repositories/DebtCategoryRepository.ts | 44 +- .../src/repositories/DebtPaymentRepository.ts | 64 +-- .../src/repositories/InvoiceRepository.ts | 18 +- .../src/repositories/LiabilityRepository.ts | 35 +- .../NetWorthSnapshotRepository.ts | 29 +- .../src/repositories/UserRepository.ts | 6 +- .../repositories/interfaces/IRepository.ts | 3 +- backend-api/src/routes/assets.ts | 40 +- backend-api/src/routes/auth.ts | 20 +- backend-api/src/routes/cashflow.routes.ts | 390 ++++++++++-------- backend-api/src/routes/client.routes.ts | 94 ++--- backend-api/src/routes/dashboard.routes.ts | 36 +- backend-api/src/routes/debt.routes.ts | 230 +++++------ backend-api/src/routes/invoice.routes.ts | 138 +++---- backend-api/src/routes/liability.routes.ts | 102 ++--- backend-api/src/routes/networth.routes.ts | 114 ++--- backend-api/src/server.ts | 36 +- backend-api/src/services/AssetService.ts | 23 +- backend-api/src/services/AuthService.ts | 2 +- backend-api/src/services/CashflowService.ts | 12 +- backend-api/src/services/ClientService.ts | 4 +- backend-api/src/services/DashboardService.ts | 20 +- .../src/services/DebtAccountService.ts | 4 +- .../src/services/DebtCategoryService.ts | 10 +- .../src/services/DebtPaymentService.ts | 8 +- backend-api/src/services/InvoiceService.ts | 19 +- backend-api/src/services/LiabilityService.ts | 4 +- backend-api/src/services/NetWorthService.ts | 23 +- backend-api/src/types/fastify.d.ts | 1 - backend-api/src/utils/password.ts | 2 +- bun.lock | 355 ++++++++++++++++ frontend-web/.env.example | 1 + frontend-web/.gitignore | 4 + frontend-web/src/App.tsx | 32 +- .../src/components/dialogs/AddAssetDialog.tsx | 8 +- .../components/dialogs/AddLiabilityDialog.tsx | 10 +- .../components/dialogs/EditAssetDialog.tsx | 13 +- .../dialogs/EditLiabilityDialog.tsx | 13 +- .../src/components/dialogs/LoginDialog.tsx | 36 +- .../src/components/dialogs/SignUpDialog.tsx | 40 +- frontend-web/src/lib/api/auth.service.ts | 71 ++++ frontend-web/src/lib/api/cashflow.service.ts | 88 ++++ frontend-web/src/lib/api/client.ts | 110 +++++ frontend-web/src/lib/api/networth.service.ts | 144 +++++++ frontend-web/src/lib/api/token.ts | 37 ++ frontend-web/src/pages/NetWorthPage.tsx | 13 +- frontend-web/src/store/index.ts | 13 +- .../src/store/slices/cashflowSlice.ts | 263 +++++------- frontend-web/src/store/slices/debtsSlice.ts | 98 +---- .../src/store/slices/invoicesSlice.ts | 124 +----- .../src/store/slices/netWorthSlice.ts | 236 ++++++++--- frontend-web/src/store/slices/userSlice.ts | 86 +++- package-lock.json | 305 ++++++++++++++ 74 files changed, 2599 insertions(+), 1386 deletions(-) create mode 100644 bun.lock create mode 100644 frontend-web/.env.example create mode 100644 frontend-web/src/lib/api/auth.service.ts create mode 100644 frontend-web/src/lib/api/cashflow.service.ts create mode 100644 frontend-web/src/lib/api/client.ts create mode 100644 frontend-web/src/lib/api/networth.service.ts create mode 100644 frontend-web/src/lib/api/token.ts create mode 100644 package-lock.json diff --git a/backend-api/ARCHITECTURE.md b/backend-api/ARCHITECTURE.md index 6a42cac..51efb67 100644 --- a/backend-api/ARCHITECTURE.md +++ b/backend-api/ARCHITECTURE.md @@ -7,6 +7,7 @@ This backend API is built following **SOLID principles** and **clean architectur ## SOLID Principles Implementation ### 1. Single Responsibility Principle (SRP) + Each class has one well-defined responsibility: - **Controllers** - Handle HTTP requests/responses only @@ -15,23 +16,25 @@ Each class has one well-defined responsibility: - **Middleware** - Handle cross-cutting concerns (auth, errors) Example: + ```typescript // AssetService - ONLY handles asset business logic export class AssetService { - async create(userId: string, data: CreateAssetDTO): Promise - async update(id: string, userId: string, data: UpdateAssetDTO): Promise + async create(userId: string, data: CreateAssetDTO): Promise; + async update(id: string, userId: string, data: UpdateAssetDTO): Promise; // ... } // AssetRepository - ONLY handles database operations export class AssetRepository { - async findById(id: string): Promise - async create(data: Prisma.AssetCreateInput): Promise + async findById(id: string): Promise; + async create(data: Prisma.AssetCreateInput): Promise; // ... } ``` ### 2. Open/Closed Principle (OCP) + The system is open for extension but closed for modification: - **Custom Error Classes** - Extend `AppError` base class for new error types @@ -39,6 +42,7 @@ The system is open for extension but closed for modification: - **Service Pattern** - Add new services without modifying existing ones Example: + ```typescript // Extensible error hierarchy export abstract class AppError extends Error { @@ -55,6 +59,7 @@ export class ValidationError extends AppError { ``` ### 3. Liskov Substitution Principle (LSP) + Derived classes can substitute their base classes: ```typescript @@ -71,6 +76,7 @@ export interface IUserScopedRepository extends Omit, 'findAll' ``` ### 4. Interface Segregation Principle (ISP) + Clients depend only on interfaces they use: - `IRepository` - Base CRUD operations @@ -78,6 +84,7 @@ Clients depend only on interfaces they use: - Specific methods in services (e.g., `getTotalValue()` in AssetService) ### 5. Dependency Inversion Principle (DIP) + High-level modules depend on abstractions: ```typescript @@ -99,6 +106,7 @@ class DatabaseConnection { ## Architecture Layers ### 1. Presentation Layer (Controllers & Routes) + - **Location**: `src/controllers/`, `src/routes/` - **Purpose**: Handle HTTP requests/responses - **Responsibilities**: @@ -119,6 +127,7 @@ export class AssetController { ``` ### 2. Business Logic Layer (Services) + - **Location**: `src/services/` - **Purpose**: Implement business rules - **Responsibilities**: @@ -139,6 +148,7 @@ export class InvoiceService { ``` ### 3. Data Access Layer (Repositories) + - **Location**: `src/repositories/` - **Purpose**: Abstract database operations - **Responsibilities**: @@ -151,13 +161,14 @@ export class AssetRepository implements IUserScopedRepository { async findAllByUser(userId: string): Promise { return prisma.asset.findMany({ where: {userId}, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } } ``` ### 4. Cross-Cutting Concerns (Middleware & Utils) + - **Location**: `src/middleware/`, `src/utils/` - **Purpose**: Handle common functionality - **Components**: @@ -221,21 +232,25 @@ User ## Security Features ### 1. Authentication + - JWT tokens with configurable expiration - Secure password hashing (bcrypt with 10 rounds) - Password complexity requirements ### 2. Authorization + - User-scoped data access - Repository methods verify ownership - Middleware extracts authenticated user ### 3. Input Validation + - Zod schemas for runtime validation - Type-safe request/response handling - SQL injection prevention (Prisma ORM) ### 4. Error Handling + - Custom error classes - No sensitive information in error messages - Proper HTTP status codes @@ -243,6 +258,7 @@ User ## API Design ### RESTful Conventions + - `GET /api/resources` - List all - `GET /api/resources/:id` - Get one - `POST /api/resources` - Create @@ -251,11 +267,16 @@ User - `DELETE /api/resources/:id` - Delete ### Response Format + ```json { - "resource": { /* data */ }, + "resource": { + /* data */ + }, // or - "resources": [ /* array */ ], + "resources": [ + /* array */ + ], // or on error "error": "ErrorType", "message": "Human-readable message" @@ -263,6 +284,7 @@ User ``` ### HTTP Status Codes + - `200 OK` - Successful GET/PUT/PATCH - `201 Created` - Successful POST - `204 No Content` - Successful DELETE @@ -276,16 +298,19 @@ User ## Testing Strategy ### Unit Tests + - Test services in isolation - Mock repository dependencies - Test business logic thoroughly ### Integration Tests + - Test API endpoints - Use test database - Verify request/response flow ### E2E Tests + - Test complete user flows - Verify authentication - Test error scenarios @@ -293,16 +318,19 @@ User ## Performance Considerations ### Database + - Indexes on frequently queried fields - Connection pooling (Prisma) - Efficient query composition ### Caching + - JWT tokens cached in client - Consider Redis for session management - Database query result caching ### Scalability + - Stateless API (horizontal scaling) - Database migrations for schema changes - Environment-based configuration @@ -328,18 +356,21 @@ User ## Best Practices ### Code Organization + ✅ One class per file ✅ Group related files in directories ✅ Use barrel exports (index.ts) ✅ Consistent naming conventions ### Error Handling + ✅ Use custom error classes ✅ Validate at boundaries ✅ Log errors appropriately ✅ Return user-friendly messages ### Security + ✅ Never log sensitive data ✅ Validate all inputs ✅ Use parameterized queries (Prisma) @@ -347,6 +378,7 @@ User ✅ Keep dependencies updated ### Documentation + ✅ JSDoc comments for public APIs ✅ README for setup instructions ✅ API documentation (Swagger) diff --git a/backend-api/CLAUDE.md b/backend-api/CLAUDE.md index ebda995..8ea834d 100644 --- a/backend-api/CLAUDE.md +++ b/backend-api/CLAUDE.md @@ -1,6 +1,6 @@ --- description: Use Bun instead of Node.js, npm, pnpm, or vite. -globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +globs: '*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json' alwaysApply: false --- diff --git a/backend-api/index.ts b/backend-api/index.ts index f67b2c6..3f2f3f8 100644 --- a/backend-api/index.ts +++ b/backend-api/index.ts @@ -1 +1 @@ -console.log("Hello via Bun!"); \ No newline at end of file +console.log('Hello via Bun!'); diff --git a/backend-api/src/config/database.ts b/backend-api/src/config/database.ts index 7521d49..7831e5b 100644 --- a/backend-api/src/config/database.ts +++ b/backend-api/src/config/database.ts @@ -12,7 +12,7 @@ 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/config/env.ts b/backend-api/src/config/env.ts index 0403c8c..f7cae61 100644 --- a/backend-api/src/config/env.ts +++ b/backend-api/src/config/env.ts @@ -10,7 +10,7 @@ const envSchema = z.object({ DATABASE_URL: z.string().min(1), JWT_SECRET: z.string().min(32), JWT_EXPIRES_IN: z.string().default('7d'), - CORS_ORIGIN: z.string().default('http://localhost:5174'), + CORS_ORIGIN: z.string().default('http://localhost:5174') }); type EnvConfig = z.infer; diff --git a/backend-api/src/controllers/AssetController.ts b/backend-api/src/controllers/AssetController.ts index 517ab91..f54a288 100644 --- a/backend-api/src/controllers/AssetController.ts +++ b/backend-api/src/controllers/AssetController.ts @@ -9,13 +9,13 @@ const ASSET_TYPES = ['cash', 'investment', 'property', 'vehicle', 'other'] as co const createAssetSchema = z.object({ name: z.string().min(1), type: z.enum(ASSET_TYPES), - value: z.number().min(0), + value: z.number().min(0) }); const updateAssetSchema = z.object({ name: z.string().min(1).optional(), type: z.enum(ASSET_TYPES).optional(), - value: z.number().min(0).optional(), + value: z.number().min(0).optional() }); /** @@ -32,12 +32,14 @@ export class AssetController { async getAll(request: FastifyRequest, reply: FastifyReply) { const userId = getUserId(request); const assets = await this.assetService.getAll(userId); + return reply.send({assets}); } async getById(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) { const userId = getUserId(request); const asset = await this.assetService.getById(request.params.id, userId); + return reply.send({asset}); } @@ -45,6 +47,7 @@ export class AssetController { const userId = getUserId(request); const data = createAssetSchema.parse(request.body); const asset = await this.assetService.create(userId, data); + return reply.status(201).send({asset}); } @@ -52,12 +55,14 @@ export class AssetController { const userId = getUserId(request); const data = updateAssetSchema.parse(request.body); const asset = await this.assetService.update(request.params.id, userId, data); + return reply.send({asset}); } async delete(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) { const userId = getUserId(request); await this.assetService.delete(request.params.id, userId); + return reply.status(204).send(); } } diff --git a/backend-api/src/controllers/AuthController.ts b/backend-api/src/controllers/AuthController.ts index 07b0b28..44f2ee6 100644 --- a/backend-api/src/controllers/AuthController.ts +++ b/backend-api/src/controllers/AuthController.ts @@ -8,12 +8,12 @@ import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository'; const registerSchema = z.object({ email: z.string().email(), password: z.string().min(8), - name: z.string().min(1), + name: z.string().min(1) }); const loginSchema = z.object({ email: z.string().email(), - password: z.string(), + password: z.string() }); /** @@ -37,12 +37,12 @@ export class AuthController { const token = request.server.jwt.sign({ id: user.id, - email: user.email, + email: user.email }); return reply.status(201).send({ user, - token, + token }); } @@ -52,14 +52,14 @@ export class AuthController { const token = request.server.jwt.sign({ id: user.id, - email: user.email, + email: user.email }); const {password: _, ...userWithoutPassword} = user; return reply.send({ user: userWithoutPassword, - token, + token }); } diff --git a/backend-api/src/controllers/CashflowController.ts b/backend-api/src/controllers/CashflowController.ts index 48d72ee..c082256 100644 --- a/backend-api/src/controllers/CashflowController.ts +++ b/backend-api/src/controllers/CashflowController.ts @@ -7,7 +7,7 @@ const createIncomeSchema = z.object({ name: z.string().min(1).max(255), amount: z.number().min(0.01), frequency: z.string(), - notes: z.string().optional(), + notes: z.string().optional() }); const updateIncomeSchema = createIncomeSchema.partial(); @@ -17,8 +17,11 @@ const createExpenseSchema = z.object({ amount: z.number().min(0.01), category: z.string(), frequency: z.string(), - dueDate: z.string().transform(str => new Date(str)).optional(), - notes: z.string().optional(), + dueDate: z + .string() + .transform(str => new Date(str)) + .optional(), + notes: z.string().optional() }); const updateExpenseSchema = createExpenseSchema.partial(); @@ -29,7 +32,7 @@ const createTransactionSchema = z.object({ amount: z.number().min(0.01), date: z.string().transform(str => new Date(str)), description: z.string().optional(), - notes: z.string().optional(), + notes: z.string().optional() }); /** @@ -151,11 +154,7 @@ export class CashflowController { } if (startDate && endDate) { - const transactions = await this.cashflowService.getTransactionsByDateRange( - userId, - new Date(startDate), - new Date(endDate) - ); + const transactions = await this.cashflowService.getTransactionsByDateRange(userId, new Date(startDate), new Date(endDate)); return reply.send({transactions}); } @@ -181,11 +180,7 @@ export class CashflowController { const userId = getUserId(request); const {startDate, endDate} = request.query as {startDate: string; endDate: string}; - const summary = await this.cashflowService.getCashflowSummary( - userId, - new Date(startDate), - new Date(endDate) - ); + const summary = await this.cashflowService.getCashflowSummary(userId, new Date(startDate), new Date(endDate)); return reply.send(summary); } diff --git a/backend-api/src/controllers/ClientController.ts b/backend-api/src/controllers/ClientController.ts index 539bb5b..40b7287 100644 --- a/backend-api/src/controllers/ClientController.ts +++ b/backend-api/src/controllers/ClientController.ts @@ -8,7 +8,7 @@ const createClientSchema = z.object({ email: z.string().email(), phone: z.string().max(50).optional(), address: z.string().optional(), - notes: z.string().optional(), + notes: z.string().optional() }); const updateClientSchema = z.object({ @@ -16,7 +16,7 @@ const updateClientSchema = z.object({ email: z.string().email().optional(), phone: z.string().max(50).optional(), address: z.string().optional(), - notes: z.string().optional(), + notes: z.string().optional() }); /** diff --git a/backend-api/src/controllers/DebtAccountController.ts b/backend-api/src/controllers/DebtAccountController.ts index 64a7d72..a51393b 100644 --- a/backend-api/src/controllers/DebtAccountController.ts +++ b/backend-api/src/controllers/DebtAccountController.ts @@ -12,8 +12,11 @@ const createAccountSchema = z.object({ currentBalance: z.number().min(0), interestRate: z.number().min(0).max(100).optional(), minimumPayment: z.number().min(0).optional(), - dueDate: z.string().transform(str => new Date(str)).optional(), - notes: z.string().optional(), + dueDate: z + .string() + .transform(str => new Date(str)) + .optional(), + notes: z.string().optional() }); const updateAccountSchema = z.object({ @@ -23,8 +26,11 @@ const updateAccountSchema = z.object({ currentBalance: z.number().min(0).optional(), interestRate: z.number().min(0).max(100).optional(), minimumPayment: z.number().min(0).optional(), - dueDate: z.string().transform(str => new Date(str)).optional(), - notes: z.string().optional(), + dueDate: z + .string() + .transform(str => new Date(str)) + .optional(), + notes: z.string().optional() }); /** diff --git a/backend-api/src/controllers/DebtCategoryController.ts b/backend-api/src/controllers/DebtCategoryController.ts index c332dbf..a7aaf30 100644 --- a/backend-api/src/controllers/DebtCategoryController.ts +++ b/backend-api/src/controllers/DebtCategoryController.ts @@ -6,13 +6,19 @@ import {z} from 'zod'; const createCategorySchema = z.object({ name: z.string().min(1).max(255), description: z.string().optional(), - color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(), + color: z + .string() + .regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/) + .optional() }); const updateCategorySchema = z.object({ name: z.string().min(1).max(255).optional(), description: z.string().optional(), - color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(), + color: z + .string() + .regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/) + .optional() }); /** diff --git a/backend-api/src/controllers/DebtPaymentController.ts b/backend-api/src/controllers/DebtPaymentController.ts index 0671cf5..eb04b65 100644 --- a/backend-api/src/controllers/DebtPaymentController.ts +++ b/backend-api/src/controllers/DebtPaymentController.ts @@ -7,7 +7,7 @@ const createPaymentSchema = z.object({ accountId: z.string().uuid(), amount: z.number().min(0.01), paymentDate: z.string().transform(str => new Date(str)), - notes: z.string().optional(), + notes: z.string().optional() }); /** @@ -46,11 +46,7 @@ export class DebtPaymentController { } if (startDate && endDate) { - const payments = await this.paymentService.getByDateRange( - userId, - new Date(startDate), - new Date(endDate) - ); + const payments = await this.paymentService.getByDateRange(userId, new Date(startDate), new Date(endDate)); return reply.send({payments}); } diff --git a/backend-api/src/controllers/InvoiceController.ts b/backend-api/src/controllers/InvoiceController.ts index df0cdf0..ae9a0c5 100644 --- a/backend-api/src/controllers/InvoiceController.ts +++ b/backend-api/src/controllers/InvoiceController.ts @@ -7,7 +7,7 @@ const lineItemSchema = z.object({ description: z.string().min(1), quantity: z.number().min(1), unitPrice: z.number().min(0), - amount: z.number().min(0), + amount: z.number().min(0) }); const createInvoiceSchema = z.object({ @@ -16,19 +16,25 @@ const createInvoiceSchema = z.object({ dueDate: z.string().transform(str => new Date(str)), lineItems: z.array(lineItemSchema).min(1), notes: z.string().optional(), - terms: z.string().optional(), + terms: z.string().optional() }); const updateInvoiceSchema = z.object({ - issueDate: z.string().transform(str => new Date(str)).optional(), - dueDate: z.string().transform(str => new Date(str)).optional(), + issueDate: z + .string() + .transform(str => new Date(str)) + .optional(), + dueDate: z + .string() + .transform(str => new Date(str)) + .optional(), lineItems: z.array(lineItemSchema).min(1).optional(), notes: z.string().optional(), - terms: z.string().optional(), + terms: z.string().optional() }); const updateStatusSchema = z.object({ - status: z.enum(['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED']), + status: z.enum(['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED']) }); /** @@ -59,7 +65,7 @@ export class InvoiceController { const invoices = await this.invoiceService.getAllByUser(userId, { clientId, - status, + status }); return reply.send({invoices}); diff --git a/backend-api/src/controllers/LiabilityController.ts b/backend-api/src/controllers/LiabilityController.ts index 711ed0c..f406b08 100644 --- a/backend-api/src/controllers/LiabilityController.ts +++ b/backend-api/src/controllers/LiabilityController.ts @@ -9,9 +9,12 @@ const createLiabilitySchema = z.object({ currentBalance: z.number().min(0), interestRate: z.number().min(0).max(100).optional(), minimumPayment: z.number().min(0).optional(), - dueDate: z.string().transform(str => new Date(str)).optional(), + dueDate: z + .string() + .transform(str => new Date(str)) + .optional(), creditor: z.string().max(255).optional(), - notes: z.string().optional(), + notes: z.string().optional() }); const updateLiabilitySchema = z.object({ @@ -20,9 +23,12 @@ const updateLiabilitySchema = z.object({ currentBalance: z.number().min(0).optional(), interestRate: z.number().min(0).max(100).optional(), minimumPayment: z.number().min(0).optional(), - dueDate: z.string().transform(str => new Date(str)).optional(), + dueDate: z + .string() + .transform(str => new Date(str)) + .optional(), creditor: z.string().max(255).optional(), - notes: z.string().optional(), + notes: z.string().optional() }); /** diff --git a/backend-api/src/controllers/NetWorthController.ts b/backend-api/src/controllers/NetWorthController.ts index 0846247..6c1fa3f 100644 --- a/backend-api/src/controllers/NetWorthController.ts +++ b/backend-api/src/controllers/NetWorthController.ts @@ -8,16 +8,16 @@ const createSnapshotSchema = z.object({ totalAssets: z.number().min(0), totalLiabilities: z.number().min(0), netWorth: z.number(), - notes: z.string().optional(), + notes: z.string().optional() }); const createFromCurrentSchema = z.object({ - notes: z.string().optional(), + notes: z.string().optional() }); const dateRangeSchema = z.object({ startDate: z.string().transform(str => new Date(str)), - endDate: z.string().transform(str => new Date(str)), + endDate: z.string().transform(str => new Date(str)) }); /** @@ -55,11 +55,7 @@ export class NetWorthController { const {startDate, endDate} = request.query as {startDate: string; endDate: string}; const parsed = dateRangeSchema.parse({startDate, endDate}); - const snapshots = await this.netWorthService.getSnapshotsByDateRange( - userId, - parsed.startDate, - parsed.endDate - ); + const snapshots = await this.netWorthService.getSnapshotsByDateRange(userId, parsed.startDate, parsed.endDate); return reply.send({snapshots}); } @@ -119,10 +115,7 @@ export class NetWorthController { const userId = getUserId(request); const {limit} = request.query as {limit?: string}; - const stats = await this.netWorthService.getGrowthStats( - userId, - limit ? parseInt(limit) : undefined - ); + const stats = await this.netWorthService.getGrowthStats(userId, limit ? parseInt(limit) : undefined); return reply.send({stats}); } diff --git a/backend-api/src/index.ts b/backend-api/src/index.ts index 64aa494..a12daee 100644 --- a/backend-api/src/index.ts +++ b/backend-api/src/index.ts @@ -12,7 +12,7 @@ async function main() { // Start server await server.listen({ port: env.PORT, - host: '0.0.0.0', + host: '0.0.0.0' }); server.log.info(`🚀 Server listening on http://localhost:${env.PORT}`); diff --git a/backend-api/src/middleware/errorHandler.ts b/backend-api/src/middleware/errorHandler.ts index cdc87db..8a80713 100644 --- a/backend-api/src/middleware/errorHandler.ts +++ b/backend-api/src/middleware/errorHandler.ts @@ -14,7 +14,7 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest, if (error instanceof AppError) { return reply.status(error.statusCode).send({ error: error.name, - message: error.message, + message: error.message }); } @@ -23,7 +23,7 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest, return reply.status(400).send({ error: 'ValidationError', message: 'Invalid request data', - details: error.errors, + details: error.errors }); } @@ -32,7 +32,7 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest, return reply.status(400).send({ error: 'ValidationError', message: error.message, - details: error.validation, + details: error.validation }); } @@ -42,13 +42,13 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest, if (prismaError.code === 'P2002') { return reply.status(409).send({ error: 'ConflictError', - message: 'A record with this value already exists', + message: 'A record with this value already exists' }); } if (prismaError.code === 'P2025') { return reply.status(404).send({ error: 'NotFoundError', - message: 'Record not found', + message: 'Record not found' }); } } @@ -59,6 +59,6 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest, return reply.status(statusCode).send({ error: 'ServerError', - message, + message }); } diff --git a/backend-api/src/repositories/AssetRepository.ts b/backend-api/src/repositories/AssetRepository.ts index 0ea0377..105fc07 100644 --- a/backend-api/src/repositories/AssetRepository.ts +++ b/backend-api/src/repositories/AssetRepository.ts @@ -12,14 +12,14 @@ export class AssetRepository { async findByIdAndUser(id: string, userId: string): Promise { return prisma.asset.findFirst({ - where: {id, userId}, + where: {id, userId} }); } async findAllByUser(userId: string, filters?: Record): Promise { return prisma.asset.findMany({ where: {userId, ...filters}, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } @@ -30,7 +30,7 @@ export class AssetRepository { async update(id: string, data: Prisma.AssetUpdateInput): Promise { return prisma.asset.update({ where: {id}, - data, + data }); } @@ -41,7 +41,7 @@ export class AssetRepository { async getTotalValue(userId: string): Promise { const result = await prisma.asset.aggregate({ where: {userId}, - _sum: {value: true}, + _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 9c1e17e..17e7d1b 100644 --- a/backend-api/src/repositories/CashflowRepository.ts +++ b/backend-api/src/repositories/CashflowRepository.ts @@ -18,7 +18,7 @@ export class IncomeSourceRepository { async findAllByUser(userId: string): Promise { return prisma.incomeSource.findMany({ where: {userId}, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } @@ -37,7 +37,7 @@ export class IncomeSourceRepository { async getTotalMonthlyIncome(userId: string): Promise { const result = await prisma.incomeSource.aggregate({ where: {userId}, - _sum: {amount: true}, + _sum: {amount: true} }); return result._sum.amount || 0; } @@ -58,7 +58,7 @@ export class ExpenseRepository { async findAllByUser(userId: string): Promise { return prisma.expense.findMany({ where: {userId}, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } @@ -77,18 +77,21 @@ export class ExpenseRepository { async getTotalMonthlyExpenses(userId: string): Promise { const result = await prisma.expense.aggregate({ where: {userId}, - _sum: {amount: true}, + _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); + return expenses.reduce( + (acc, expense) => { + if (!acc[expense.category]) acc[expense.category] = []; + acc[expense.category].push(expense); + return acc; + }, + {} as Record + ); } } @@ -107,7 +110,7 @@ export class TransactionRepository { async findAllByUser(userId: string): Promise { return prisma.transaction.findMany({ where: {userId}, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } @@ -127,38 +130,38 @@ export class TransactionRepository { return prisma.transaction.findMany({ where: { userId, - date: {gte: startDate, lte: endDate}, + date: {gte: startDate, lte: endDate} }, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } async getByType(userId: string, type: string): Promise { return prisma.transaction.findMany({ where: {userId, type}, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } - async getCashflowSummary(userId: string, startDate: Date, endDate: Date): Promise<{ + 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 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); + const totalExpenses = transactions.filter(t => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0); return { totalIncome, totalExpenses, - netCashflow: totalIncome - totalExpenses, + netCashflow: totalIncome - totalExpenses }; } } diff --git a/backend-api/src/repositories/ClientRepository.ts b/backend-api/src/repositories/ClientRepository.ts index 2acf689..40ba1c1 100644 --- a/backend-api/src/repositories/ClientRepository.ts +++ b/backend-api/src/repositories/ClientRepository.ts @@ -12,8 +12,8 @@ export class ClientRepository { return prisma.client.findUnique({ where: {id}, include: { - invoices: true, - }, + invoices: true + } }); } @@ -21,8 +21,8 @@ export class ClientRepository { return prisma.client.findFirst({ where: {id, userId}, include: { - invoices: true, - }, + invoices: true + } }); } @@ -31,10 +31,10 @@ export class ClientRepository { where: {userId}, include: { invoices: { - orderBy: {createdAt: 'desc'}, - }, + orderBy: {createdAt: 'desc'} + } }, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } @@ -42,8 +42,8 @@ export class ClientRepository { return prisma.client.create({ data, include: { - invoices: true, - }, + invoices: true + } }); } @@ -52,14 +52,14 @@ export class ClientRepository { where: {id}, data, include: { - invoices: true, - }, + invoices: true + } }); } async delete(id: string): Promise { await prisma.client.delete({ - where: {id}, + where: {id} }); } @@ -70,8 +70,8 @@ export class ClientRepository { return prisma.client.findFirst({ where: { userId, - email, - }, + email + } }); } @@ -82,13 +82,13 @@ export class ClientRepository { const result = await prisma.invoice.aggregate({ where: { client: { - userId, + userId }, - status: 'paid', + status: 'paid' }, _sum: { - total: true, - }, + total: true + } }); return result._sum.total || 0; @@ -105,11 +105,11 @@ export class ClientRepository { select: { id: true, total: true, - status: true, - }, - }, + status: true + } + } }, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); return clients.map(client => ({ @@ -117,13 +117,9 @@ export class ClientRepository { 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), - }, + 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) + } })); } } diff --git a/backend-api/src/repositories/DebtAccountRepository.ts b/backend-api/src/repositories/DebtAccountRepository.ts index 695a4ed..4b8d945 100644 --- a/backend-api/src/repositories/DebtAccountRepository.ts +++ b/backend-api/src/repositories/DebtAccountRepository.ts @@ -14,9 +14,9 @@ export class DebtAccountRepository { include: { category: true, payments: { - orderBy: {date: 'desc'}, - }, - }, + orderBy: {date: 'desc'} + } + } }); } @@ -26,9 +26,9 @@ export class DebtAccountRepository { include: { category: true, payments: { - orderBy: {date: 'desc'}, - }, - }, + orderBy: {date: 'desc'} + } + } }); } @@ -38,10 +38,10 @@ export class DebtAccountRepository { include: { category: true, payments: { - orderBy: {date: 'desc'}, - }, + orderBy: {date: 'desc'} + } }, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } @@ -50,10 +50,10 @@ export class DebtAccountRepository { where: {categoryId}, include: { payments: { - orderBy: {date: 'desc'}, - }, + orderBy: {date: 'desc'} + } }, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } @@ -62,8 +62,8 @@ export class DebtAccountRepository { data, include: { category: true, - payments: true, - }, + payments: true + } }); } @@ -73,14 +73,14 @@ export class DebtAccountRepository { data, include: { category: true, - payments: true, - }, + payments: true + } }); } async delete(id: string): Promise { await prisma.debtAccount.delete({ - where: {id}, + where: {id} }); } @@ -91,8 +91,8 @@ export class DebtAccountRepository { const result = await prisma.debtAccount.aggregate({ where: {userId}, _sum: { - currentBalance: true, - }, + currentBalance: true + } }); return result._sum.currentBalance || 0; @@ -107,9 +107,9 @@ export class DebtAccountRepository { include: { category: true, payments: { - orderBy: {date: 'desc'}, - }, - }, + orderBy: {date: 'desc'} + } + } }); return accounts.map(account => { @@ -122,8 +122,8 @@ export class DebtAccountRepository { totalPaid, numberOfPayments: account.payments.length, lastPaymentDate: lastPayment?.date || null, - lastPaymentAmount: lastPayment?.amount || null, - }, + lastPaymentAmount: lastPayment?.amount || null + } }; }); } diff --git a/backend-api/src/repositories/DebtCategoryRepository.ts b/backend-api/src/repositories/DebtCategoryRepository.ts index 446b040..ab9de57 100644 --- a/backend-api/src/repositories/DebtCategoryRepository.ts +++ b/backend-api/src/repositories/DebtCategoryRepository.ts @@ -15,10 +15,10 @@ export class DebtCategoryRepository implements IUserScopedRepository { await prisma.debtCategory.delete({ - where: {id}, + where: {id} }); } @@ -69,8 +69,8 @@ export class DebtCategoryRepository implements IUserScopedRepository { 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 - ); + 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, - }, + totalPayments + } }; }) ); diff --git a/backend-api/src/repositories/DebtPaymentRepository.ts b/backend-api/src/repositories/DebtPaymentRepository.ts index cc53a90..714db29 100644 --- a/backend-api/src/repositories/DebtPaymentRepository.ts +++ b/backend-api/src/repositories/DebtPaymentRepository.ts @@ -14,17 +14,17 @@ export class DebtPaymentRepository { include: { account: { include: { - category: true, - }, - }, - }, + category: true + } + } + } }); } async findByAccount(accountId: string): Promise { return prisma.debtPayment.findMany({ where: {accountId}, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } @@ -33,18 +33,18 @@ export class DebtPaymentRepository { where: { account: { category: { - userId, - }, - }, + userId + } + } }, include: { account: { include: { - category: true, - }, - }, + category: true + } + } }, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } @@ -54,16 +54,16 @@ export class DebtPaymentRepository { include: { account: { include: { - category: true, - }, - }, - }, + category: true + } + } + } }); } async delete(id: string): Promise { await prisma.debtPayment.delete({ - where: {id}, + where: {id} }); } @@ -74,8 +74,8 @@ export class DebtPaymentRepository { const result = await prisma.debtPayment.aggregate({ where: {accountId}, _sum: { - amount: true, - }, + amount: true + } }); return result._sum.amount || 0; @@ -89,13 +89,13 @@ export class DebtPaymentRepository { where: { account: { category: { - userId, - }, - }, + userId + } + } }, _sum: { - amount: true, - }, + amount: true + } }); return result._sum.amount || 0; @@ -109,22 +109,22 @@ export class DebtPaymentRepository { where: { account: { category: { - userId, - }, + userId + } }, date: { gte: startDate, - lte: endDate, - }, + lte: endDate + } }, include: { account: { include: { - category: true, - }, - }, + category: true + } + } }, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } } diff --git a/backend-api/src/repositories/InvoiceRepository.ts b/backend-api/src/repositories/InvoiceRepository.ts index ae6e4ce..6218686 100644 --- a/backend-api/src/repositories/InvoiceRepository.ts +++ b/backend-api/src/repositories/InvoiceRepository.ts @@ -15,14 +15,14 @@ export class InvoiceRepository implements IUserScopedRepository { async findById(id: string): Promise { return prisma.invoice.findUnique({ where: {id}, - include: {lineItems: true, client: true}, + include: {lineItems: true, client: true} }) as unknown as Invoice; } async findByIdAndUser(id: string, userId: string): Promise { return prisma.invoice.findFirst({ where: {id, userId}, - include: {lineItems: true, client: true}, + include: {lineItems: true, client: true} }); } @@ -30,14 +30,14 @@ export class InvoiceRepository implements IUserScopedRepository { return prisma.invoice.findMany({ where: {userId, ...filters}, include: {lineItems: true, client: true}, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } async create(data: Prisma.InvoiceCreateInput): Promise { return prisma.invoice.create({ data, - include: {lineItems: true, client: true}, + include: {lineItems: true, client: true} }) as unknown as Invoice; } @@ -45,7 +45,7 @@ export class InvoiceRepository implements IUserScopedRepository { return prisma.invoice.update({ where: {id}, data, - include: {lineItems: true, client: true}, + include: {lineItems: true, client: true} }) as unknown as Invoice; } @@ -58,8 +58,8 @@ export class InvoiceRepository implements IUserScopedRepository { where: { userId, invoiceNumber, - ...(excludeId && {id: {not: excludeId}}), - }, + ...(excludeId && {id: {not: excludeId}}) + } }); return count > 0; } @@ -69,8 +69,8 @@ export class InvoiceRepository implements IUserScopedRepository { const count = await prisma.invoice.count({ where: { userId, - invoiceNumber: {startsWith: `INV-${year}-`}, - }, + 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 45a8d2e..da8c336 100644 --- a/backend-api/src/repositories/LiabilityRepository.ts +++ b/backend-api/src/repositories/LiabilityRepository.ts @@ -10,39 +10,39 @@ const prisma = DatabaseConnection.getInstance(); export class LiabilityRepository { async findById(id: string): Promise { return prisma.liability.findUnique({ - where: {id}, + where: {id} }); } async findByIdAndUser(id: string, userId: string): Promise { return prisma.liability.findFirst({ - where: {id, userId}, + where: {id, userId} }); } async findAllByUser(userId: string): Promise { return prisma.liability.findMany({ where: {userId}, - orderBy: {createdAt: 'desc'}, + orderBy: {createdAt: 'desc'} }); } async create(data: Prisma.LiabilityCreateInput): Promise { return prisma.liability.create({ - data, + data }); } async update(id: string, data: Prisma.LiabilityUpdateInput): Promise { return prisma.liability.update({ where: {id}, - data, + data }); } async delete(id: string): Promise { await prisma.liability.delete({ - where: {id}, + where: {id} }); } @@ -53,8 +53,8 @@ export class LiabilityRepository { const result = await prisma.liability.aggregate({ where: {userId}, _sum: { - balance: true, - }, + balance: true + } }); return result._sum.balance || 0; @@ -66,13 +66,16 @@ export class LiabilityRepository { async getByType(userId: string): Promise> { 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); + return liabilities.reduce( + (acc, liability) => { + const type = liability.type; + if (!acc[type]) { + 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 295d06c..c758a71 100644 --- a/backend-api/src/repositories/NetWorthSnapshotRepository.ts +++ b/backend-api/src/repositories/NetWorthSnapshotRepository.ts @@ -11,33 +11,33 @@ const prisma = DatabaseConnection.getInstance(); export class NetWorthSnapshotRepository implements IUserScopedRepository { async findById(id: string): Promise { return prisma.netWorthSnapshot.findUnique({ - where: {id}, + where: {id} }); } async findAllByUser(userId: string): Promise { return prisma.netWorthSnapshot.findMany({ where: {userId}, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } async create(data: Prisma.NetWorthSnapshotCreateInput): Promise { return prisma.netWorthSnapshot.create({ - data, + data }); } async update(id: string, data: Prisma.NetWorthSnapshotUpdateInput): Promise { return prisma.netWorthSnapshot.update({ where: {id}, - data, + data }); } async delete(id: string): Promise { await prisma.netWorthSnapshot.delete({ - where: {id}, + where: {id} }); } @@ -47,7 +47,7 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository { return prisma.netWorthSnapshot.findFirst({ where: {userId}, - orderBy: {date: 'desc'}, + orderBy: {date: 'desc'} }); } @@ -60,10 +60,10 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository 0; @@ -88,7 +88,7 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository { name: true, createdAt: true, updatedAt: true, - password: false, // Never return password - }, + password: false // Never return password + } }) as unknown as User[]; } @@ -36,7 +36,7 @@ export class UserRepository implements IRepository { async update(id: string, data: Prisma.UserUpdateInput): Promise { return prisma.user.update({ where: {id}, - data, + data }); } diff --git a/backend-api/src/repositories/interfaces/IRepository.ts b/backend-api/src/repositories/interfaces/IRepository.ts index 2be0816..cb27efc 100644 --- a/backend-api/src/repositories/interfaces/IRepository.ts +++ b/backend-api/src/repositories/interfaces/IRepository.ts @@ -15,8 +15,7 @@ export interface IRepository { * User-scoped repository interface * For entities that belong to a specific user */ -export interface IUserScopedRepository - extends Omit, 'findAll'> { +export interface IUserScopedRepository extends Omit, 'findAll'> { findAllByUser(userId: string, filters?: Record): Promise; findByIdAndUser(id: string, userId: string): Promise; } diff --git a/backend-api/src/routes/assets.ts b/backend-api/src/routes/assets.ts index d5af2b9..f19d8c8 100644 --- a/backend-api/src/routes/assets.ts +++ b/backend-api/src/routes/assets.ts @@ -16,9 +16,9 @@ export async function assetRoutes(fastify: FastifyInstance) { schema: { tags: ['Assets'], description: 'Get all user assets', - security: [{bearerAuth: []}], + security: [{bearerAuth: []}] }, - handler: controller.getAll.bind(controller), + handler: controller.getAll.bind(controller) }); fastify.get('/:id', { @@ -29,11 +29,11 @@ export async function assetRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string', format: 'uuid'}, - }, - }, + id: {type: 'string', format: 'uuid'} + } + } }, - handler: controller.getById.bind(controller), + handler: controller.getById.bind(controller) }); fastify.post('/', { @@ -47,11 +47,11 @@ export async function assetRoutes(fastify: FastifyInstance) { properties: { name: {type: 'string'}, type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']}, - value: {type: 'number', minimum: 0}, - }, - }, + value: {type: 'number', minimum: 0} + } + } }, - handler: controller.create.bind(controller), + handler: controller.create.bind(controller) }); fastify.put('/:id', { @@ -62,19 +62,19 @@ export async function assetRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string', format: 'uuid'}, - }, + id: {type: 'string', format: 'uuid'} + } }, body: { type: 'object', properties: { name: {type: 'string'}, type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']}, - value: {type: 'number', minimum: 0}, - }, - }, + value: {type: 'number', minimum: 0} + } + } }, - handler: controller.update.bind(controller), + handler: controller.update.bind(controller) }); fastify.delete('/:id', { @@ -85,10 +85,10 @@ export async function assetRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string', format: 'uuid'}, - }, - }, + id: {type: 'string', format: 'uuid'} + } + } }, - handler: controller.delete.bind(controller), + handler: controller.delete.bind(controller) }); } diff --git a/backend-api/src/routes/auth.ts b/backend-api/src/routes/auth.ts index 7fa4a24..958f61e 100644 --- a/backend-api/src/routes/auth.ts +++ b/backend-api/src/routes/auth.ts @@ -18,11 +18,11 @@ export async function authRoutes(fastify: FastifyInstance) { properties: { email: {type: 'string', format: 'email'}, password: {type: 'string', minLength: 8}, - name: {type: 'string', minLength: 1}, - }, - }, + name: {type: 'string', minLength: 1} + } + } }, - handler: controller.register.bind(controller), + handler: controller.register.bind(controller) }); fastify.post('/login', { @@ -34,20 +34,20 @@ export async function authRoutes(fastify: FastifyInstance) { required: ['email', 'password'], properties: { email: {type: 'string', format: 'email'}, - password: {type: 'string'}, - }, - }, + password: {type: 'string'} + } + } }, - handler: controller.login.bind(controller), + handler: controller.login.bind(controller) }); fastify.get('/profile', { schema: { tags: ['Authentication'], description: 'Get current user profile', - security: [{bearerAuth: []}], + security: [{bearerAuth: []}] }, preHandler: authenticate, - handler: controller.getProfile.bind(controller), + handler: controller.getProfile.bind(controller) }); } diff --git a/backend-api/src/routes/cashflow.routes.ts b/backend-api/src/routes/cashflow.routes.ts index 4fce603..44e85e8 100644 --- a/backend-api/src/routes/cashflow.routes.ts +++ b/backend-api/src/routes/cashflow.routes.ts @@ -1,11 +1,7 @@ import {FastifyInstance} from 'fastify'; import {CashflowController} from '../controllers/CashflowController'; import {CashflowService} from '../services/CashflowService'; -import { - IncomeSourceRepository, - ExpenseRepository, - TransactionRepository, -} from '../repositories/CashflowRepository'; +import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository'; import {authenticate} from '../middleware/auth'; const incomeRepository = new IncomeSourceRepository(); @@ -19,199 +15,267 @@ export async function cashflowRoutes(fastify: FastifyInstance) { // ===== Income Source Routes ===== - fastify.get('/income', { - schema: { - description: 'Get all income sources', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.get( + '/income', + { + schema: { + description: 'Get all income sources', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.getAllIncome.bind(cashflowController)); + cashflowController.getAllIncome.bind(cashflowController) + ); - fastify.get('/income/total', { - schema: { - description: 'Get total monthly income', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.get( + '/income/total', + { + schema: { + description: 'Get total monthly income', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.getTotalMonthlyIncome.bind(cashflowController)); + cashflowController.getTotalMonthlyIncome.bind(cashflowController) + ); - fastify.get('/income/:id', { - schema: { - description: 'Get income source by ID', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.get( + '/income/:id', + { + schema: { + description: 'Get income source by ID', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.getOneIncome.bind(cashflowController)); + cashflowController.getOneIncome.bind(cashflowController) + ); - fastify.post('/income', { - schema: { - description: 'Create income source', - tags: ['Cashflow'], - security: [{bearerAuth: []}], - body: { - type: 'object', - required: ['name', 'amount', 'frequency'], - properties: { - name: {type: 'string'}, - amount: {type: 'number'}, - frequency: {type: 'string'}, - notes: {type: 'string'}, - }, - }, + fastify.post( + '/income', + { + schema: { + description: 'Create income source', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name', 'amount', 'frequency'], + properties: { + name: {type: 'string'}, + amount: {type: 'number'}, + frequency: {type: 'string'}, + notes: {type: 'string'} + } + } + } }, - }, cashflowController.createIncome.bind(cashflowController)); + cashflowController.createIncome.bind(cashflowController) + ); - fastify.put('/income/:id', { - schema: { - description: 'Update income source', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.put( + '/income/:id', + { + schema: { + description: 'Update income source', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.updateIncome.bind(cashflowController)); + cashflowController.updateIncome.bind(cashflowController) + ); - fastify.delete('/income/:id', { - schema: { - description: 'Delete income source', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.delete( + '/income/:id', + { + schema: { + description: 'Delete income source', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.deleteIncome.bind(cashflowController)); + cashflowController.deleteIncome.bind(cashflowController) + ); // ===== Expense Routes ===== - fastify.get('/expenses', { - schema: { - description: 'Get all expenses', - tags: ['Cashflow'], - security: [{bearerAuth: []}], - querystring: { - type: 'object', - properties: { - byCategory: {type: 'string', enum: ['true', 'false']}, - }, - }, + fastify.get( + '/expenses', + { + schema: { + description: 'Get all expenses', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + byCategory: {type: 'string', enum: ['true', 'false']} + } + } + } }, - }, cashflowController.getAllExpenses.bind(cashflowController)); + cashflowController.getAllExpenses.bind(cashflowController) + ); - fastify.get('/expenses/total', { - schema: { - description: 'Get total monthly expenses', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.get( + '/expenses/total', + { + schema: { + description: 'Get total monthly expenses', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.getTotalMonthlyExpenses.bind(cashflowController)); + cashflowController.getTotalMonthlyExpenses.bind(cashflowController) + ); - fastify.get('/expenses/:id', { - schema: { - description: 'Get expense by ID', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.get( + '/expenses/:id', + { + schema: { + description: 'Get expense by ID', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.getOneExpense.bind(cashflowController)); + cashflowController.getOneExpense.bind(cashflowController) + ); - fastify.post('/expenses', { - schema: { - description: 'Create expense', - tags: ['Cashflow'], - security: [{bearerAuth: []}], - body: { - type: 'object', - required: ['name', 'amount', 'category', 'frequency'], - properties: { - name: {type: 'string'}, - amount: {type: 'number'}, - category: {type: 'string'}, - frequency: {type: 'string'}, - dueDate: {type: 'string', format: 'date-time'}, - notes: {type: 'string'}, - }, - }, + fastify.post( + '/expenses', + { + schema: { + description: 'Create expense', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name', 'amount', 'category', 'frequency'], + properties: { + name: {type: 'string'}, + amount: {type: 'number'}, + category: {type: 'string'}, + frequency: {type: 'string'}, + dueDate: {type: 'string', format: 'date-time'}, + notes: {type: 'string'} + } + } + } }, - }, cashflowController.createExpense.bind(cashflowController)); + cashflowController.createExpense.bind(cashflowController) + ); - fastify.put('/expenses/:id', { - schema: { - description: 'Update expense', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.put( + '/expenses/:id', + { + schema: { + description: 'Update expense', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.updateExpense.bind(cashflowController)); + cashflowController.updateExpense.bind(cashflowController) + ); - fastify.delete('/expenses/:id', { - schema: { - description: 'Delete expense', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.delete( + '/expenses/:id', + { + schema: { + description: 'Delete expense', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.deleteExpense.bind(cashflowController)); + cashflowController.deleteExpense.bind(cashflowController) + ); // ===== Transaction Routes ===== - fastify.get('/transactions', { - schema: { - description: 'Get all transactions', - tags: ['Cashflow'], - security: [{bearerAuth: []}], - querystring: { - type: 'object', - properties: { - type: {type: 'string'}, - startDate: {type: 'string', format: 'date-time'}, - endDate: {type: 'string', format: 'date-time'}, - }, - }, + fastify.get( + '/transactions', + { + schema: { + description: 'Get all transactions', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + type: {type: 'string'}, + startDate: {type: 'string', format: 'date-time'}, + endDate: {type: 'string', format: 'date-time'} + } + } + } }, - }, cashflowController.getAllTransactions.bind(cashflowController)); + cashflowController.getAllTransactions.bind(cashflowController) + ); - fastify.get('/transactions/summary', { - schema: { - description: 'Get cashflow summary for date range', - tags: ['Cashflow'], - security: [{bearerAuth: []}], - querystring: { - type: 'object', - required: ['startDate', 'endDate'], - properties: { - startDate: {type: 'string', format: 'date-time'}, - endDate: {type: 'string', format: 'date-time'}, - }, - }, + fastify.get( + '/transactions/summary', + { + schema: { + description: 'Get cashflow summary for date range', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + required: ['startDate', 'endDate'], + properties: { + startDate: {type: 'string', format: 'date-time'}, + endDate: {type: 'string', format: 'date-time'} + } + } + } }, - }, cashflowController.getCashflowSummary.bind(cashflowController)); + cashflowController.getCashflowSummary.bind(cashflowController) + ); - fastify.get('/transactions/:id', { - schema: { - description: 'Get transaction by ID', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.get( + '/transactions/:id', + { + schema: { + description: 'Get transaction by ID', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.getOneTransaction.bind(cashflowController)); + cashflowController.getOneTransaction.bind(cashflowController) + ); - fastify.post('/transactions', { - schema: { - description: 'Create transaction', - tags: ['Cashflow'], - security: [{bearerAuth: []}], - body: { - type: 'object', - required: ['type', 'category', 'amount', 'date'], - properties: { - type: {type: 'string'}, - category: {type: 'string'}, - amount: {type: 'number'}, - date: {type: 'string', format: 'date-time'}, - description: {type: 'string'}, - notes: {type: 'string'}, - }, - }, + fastify.post( + '/transactions', + { + schema: { + description: 'Create transaction', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['type', 'category', 'amount', 'date'], + properties: { + type: {type: 'string'}, + category: {type: 'string'}, + amount: {type: 'number'}, + date: {type: 'string', format: 'date-time'}, + description: {type: 'string'}, + notes: {type: 'string'} + } + } + } }, - }, cashflowController.createTransaction.bind(cashflowController)); + cashflowController.createTransaction.bind(cashflowController) + ); - fastify.delete('/transactions/:id', { - schema: { - description: 'Delete transaction', - tags: ['Cashflow'], - security: [{bearerAuth: []}], + fastify.delete( + '/transactions/:id', + { + schema: { + description: 'Delete transaction', + tags: ['Cashflow'], + security: [{bearerAuth: []}] + } }, - }, cashflowController.deleteTransaction.bind(cashflowController)); + cashflowController.deleteTransaction.bind(cashflowController) + ); } diff --git a/backend-api/src/routes/client.routes.ts b/backend-api/src/routes/client.routes.ts index 7886de1..adec4d8 100644 --- a/backend-api/src/routes/client.routes.ts +++ b/backend-api/src/routes/client.routes.ts @@ -28,9 +28,9 @@ export async function clientRoutes(fastify: FastifyInstance) { withStats: { type: 'string', enum: ['true', 'false'], - description: 'Include invoice statistics for each client', - }, - }, + description: 'Include invoice statistics for each client' + } + } }, response: { 200: { @@ -49,14 +49,14 @@ export async function clientRoutes(fastify: FastifyInstance) { address: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } + } }, clientController.getAll.bind(clientController) ); @@ -76,11 +76,11 @@ export async function clientRoutes(fastify: FastifyInstance) { description: 'Total revenue', type: 'object', properties: { - totalRevenue: {type: 'number'}, - }, - }, - }, - }, + totalRevenue: {type: 'number'} + } + } + } + } }, clientController.getTotalRevenue.bind(clientController) ); @@ -98,8 +98,8 @@ export async function clientRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 200: { @@ -116,13 +116,13 @@ export async function clientRoutes(fastify: FastifyInstance) { address: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } }, clientController.getOne.bind(clientController) ); @@ -145,19 +145,19 @@ export async function clientRoutes(fastify: FastifyInstance) { email: {type: 'string', format: 'email'}, phone: {type: 'string', maxLength: 50}, address: {type: 'string'}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 201: { description: 'Client created successfully', type: 'object', properties: { - client: {type: 'object'}, - }, - }, - }, - }, + client: {type: 'object'} + } + } + } + } }, clientController.create.bind(clientController) ); @@ -175,8 +175,8 @@ export async function clientRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, body: { type: 'object', @@ -185,19 +185,19 @@ export async function clientRoutes(fastify: FastifyInstance) { email: {type: 'string', format: 'email'}, phone: {type: 'string', maxLength: 50}, address: {type: 'string'}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 200: { description: 'Client updated successfully', type: 'object', properties: { - client: {type: 'object'}, - }, - }, - }, - }, + client: {type: 'object'} + } + } + } + } }, clientController.update.bind(clientController) ); @@ -215,16 +215,16 @@ export async function clientRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 204: { description: 'Client deleted successfully', - type: 'null', - }, - }, - }, + type: 'null' + } + } + } }, clientController.delete.bind(clientController) ); diff --git a/backend-api/src/routes/dashboard.routes.ts b/backend-api/src/routes/dashboard.routes.ts index 2536d49..6fa928b 100644 --- a/backend-api/src/routes/dashboard.routes.ts +++ b/backend-api/src/routes/dashboard.routes.ts @@ -5,11 +5,7 @@ import {AssetRepository} from '../repositories/AssetRepository'; import {LiabilityRepository} from '../repositories/LiabilityRepository'; import {InvoiceRepository} from '../repositories/InvoiceRepository'; import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; -import { - IncomeSourceRepository, - ExpenseRepository, - TransactionRepository, -} from '../repositories/CashflowRepository'; +import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository'; import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository'; import {authenticate} from '../middleware/auth'; @@ -60,8 +56,8 @@ export async function dashboardRoutes(fastify: FastifyInstance) { assets: {type: 'number'}, liabilities: {type: 'number'}, change: {type: 'number'}, - lastUpdated: {type: 'string'}, - }, + lastUpdated: {type: 'string'} + } }, invoices: { type: 'object', @@ -69,15 +65,15 @@ export async function dashboardRoutes(fastify: FastifyInstance) { total: {type: 'number'}, paid: {type: 'number'}, outstanding: {type: 'number'}, - overdue: {type: 'number'}, - }, + overdue: {type: 'number'} + } }, debts: { type: 'object', properties: { total: {type: 'number'}, - accounts: {type: 'number'}, - }, + accounts: {type: 'number'} + } }, cashflow: { type: 'object', @@ -85,21 +81,21 @@ export async function dashboardRoutes(fastify: FastifyInstance) { monthlyIncome: {type: 'number'}, monthlyExpenses: {type: 'number'}, monthlyNet: {type: 'number'}, - last30Days: {type: 'object'}, - }, + last30Days: {type: 'object'} + } }, assets: { type: 'object', properties: { total: {type: 'number'}, count: {type: 'number'}, - allocation: {type: 'array'}, - }, - }, - }, - }, - }, - }, + allocation: {type: 'array'} + } + } + } + } + } + } }, dashboardController.getSummary.bind(dashboardController) ); diff --git a/backend-api/src/routes/debt.routes.ts b/backend-api/src/routes/debt.routes.ts index 66c283a..8fd347b 100644 --- a/backend-api/src/routes/debt.routes.ts +++ b/backend-api/src/routes/debt.routes.ts @@ -42,9 +42,9 @@ export async function debtRoutes(fastify: FastifyInstance) { withStats: { type: 'string', enum: ['true', 'false'], - description: 'Include statistics for each category', - }, - }, + description: 'Include statistics for each category' + } + } }, response: { 200: { @@ -61,14 +61,14 @@ export async function debtRoutes(fastify: FastifyInstance) { description: {type: 'string', nullable: true}, color: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } + } }, categoryController.getAll.bind(categoryController) ); @@ -86,8 +86,8 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 200: { @@ -102,13 +102,13 @@ export async function debtRoutes(fastify: FastifyInstance) { description: {type: 'string', nullable: true}, color: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } }, categoryController.getOne.bind(categoryController) ); @@ -129,19 +129,19 @@ export async function debtRoutes(fastify: FastifyInstance) { properties: { name: {type: 'string', minLength: 1, maxLength: 255}, description: {type: 'string'}, - color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}, - }, + color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'} + } }, response: { 201: { description: 'Debt category created successfully', type: 'object', properties: { - category: {type: 'object'}, - }, - }, - }, - }, + category: {type: 'object'} + } + } + } + } }, categoryController.create.bind(categoryController) ); @@ -159,27 +159,27 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, body: { type: 'object', properties: { name: {type: 'string', minLength: 1, maxLength: 255}, description: {type: 'string'}, - color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}, - }, + color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'} + } }, response: { 200: { description: 'Debt category updated successfully', type: 'object', properties: { - category: {type: 'object'}, - }, - }, - }, - }, + category: {type: 'object'} + } + } + } + } }, categoryController.update.bind(categoryController) ); @@ -197,16 +197,16 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 204: { description: 'Debt category deleted successfully', - type: 'null', - }, - }, - }, + type: 'null' + } + } + } }, categoryController.delete.bind(categoryController) ); @@ -227,19 +227,19 @@ export async function debtRoutes(fastify: FastifyInstance) { type: 'object', properties: { withStats: {type: 'string', enum: ['true', 'false']}, - categoryId: {type: 'string', description: 'Filter by category ID'}, - }, + categoryId: {type: 'string', description: 'Filter by category ID'} + } }, response: { 200: { description: 'List of debt accounts', type: 'object', properties: { - accounts: {type: 'array', items: {type: 'object'}}, - }, - }, - }, - }, + accounts: {type: 'array', items: {type: 'object'}} + } + } + } + } }, accountController.getAll.bind(accountController) ); @@ -259,11 +259,11 @@ export async function debtRoutes(fastify: FastifyInstance) { description: 'Total debt', type: 'object', properties: { - totalDebt: {type: 'number'}, - }, - }, - }, - }, + totalDebt: {type: 'number'} + } + } + } + } }, accountController.getTotalDebt.bind(accountController) ); @@ -281,19 +281,19 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 200: { description: 'Debt account details', type: 'object', properties: { - account: {type: 'object'}, - }, - }, - }, - }, + account: {type: 'object'} + } + } + } + } }, accountController.getOne.bind(accountController) ); @@ -321,19 +321,19 @@ export async function debtRoutes(fastify: FastifyInstance) { interestRate: {type: 'number', minimum: 0, maximum: 100}, minimumPayment: {type: 'number', minimum: 0}, dueDate: {type: 'string', format: 'date-time'}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 201: { description: 'Debt account created successfully', type: 'object', properties: { - account: {type: 'object'}, - }, - }, - }, - }, + account: {type: 'object'} + } + } + } + } }, accountController.create.bind(accountController) ); @@ -351,8 +351,8 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, body: { type: 'object', @@ -364,19 +364,19 @@ export async function debtRoutes(fastify: FastifyInstance) { interestRate: {type: 'number', minimum: 0, maximum: 100}, minimumPayment: {type: 'number', minimum: 0}, dueDate: {type: 'string', format: 'date-time'}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 200: { description: 'Debt account updated successfully', type: 'object', properties: { - account: {type: 'object'}, - }, - }, - }, - }, + account: {type: 'object'} + } + } + } + } }, accountController.update.bind(accountController) ); @@ -394,16 +394,16 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 204: { description: 'Debt account deleted successfully', - type: 'null', - }, - }, - }, + type: 'null' + } + } + } }, accountController.delete.bind(accountController) ); @@ -425,19 +425,19 @@ export async function debtRoutes(fastify: FastifyInstance) { properties: { accountId: {type: 'string', description: 'Filter by account ID'}, startDate: {type: 'string', format: 'date-time'}, - endDate: {type: 'string', format: 'date-time'}, - }, + endDate: {type: 'string', format: 'date-time'} + } }, response: { 200: { description: 'List of debt payments', type: 'object', properties: { - payments: {type: 'array', items: {type: 'object'}}, - }, - }, - }, - }, + payments: {type: 'array', items: {type: 'object'}} + } + } + } + } }, paymentController.getAll.bind(paymentController) ); @@ -457,11 +457,11 @@ export async function debtRoutes(fastify: FastifyInstance) { description: 'Total payments', type: 'object', properties: { - totalPayments: {type: 'number'}, - }, - }, - }, - }, + totalPayments: {type: 'number'} + } + } + } + } }, paymentController.getTotalPayments.bind(paymentController) ); @@ -479,19 +479,19 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 200: { description: 'Debt payment details', type: 'object', properties: { - payment: {type: 'object'}, - }, - }, - }, - }, + payment: {type: 'object'} + } + } + } + } }, paymentController.getOne.bind(paymentController) ); @@ -513,19 +513,19 @@ export async function debtRoutes(fastify: FastifyInstance) { accountId: {type: 'string', format: 'uuid'}, amount: {type: 'number', minimum: 0.01}, paymentDate: {type: 'string', format: 'date-time'}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 201: { description: 'Debt payment created successfully', type: 'object', properties: { - payment: {type: 'object'}, - }, - }, - }, - }, + payment: {type: 'object'} + } + } + } + } }, paymentController.create.bind(paymentController) ); @@ -543,16 +543,16 @@ export async function debtRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 204: { description: 'Debt payment deleted successfully', - type: 'null', - }, - }, - }, + type: 'null' + } + } + } }, paymentController.delete.bind(paymentController) ); diff --git a/backend-api/src/routes/invoice.routes.ts b/backend-api/src/routes/invoice.routes.ts index 52b6dd2..2b3f25f 100644 --- a/backend-api/src/routes/invoice.routes.ts +++ b/backend-api/src/routes/invoice.routes.ts @@ -28,8 +28,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) { type: 'object', properties: { clientId: {type: 'string', description: 'Filter by client ID'}, - status: {type: 'string', description: 'Filter by status'}, - }, + status: {type: 'string', description: 'Filter by status'} + } }, response: { 200: { @@ -52,14 +52,14 @@ export async function invoiceRoutes(fastify: FastifyInstance) { notes: {type: 'string', nullable: true}, terms: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } + } }, invoiceController.getAll.bind(invoiceController) ); @@ -85,13 +85,13 @@ export async function invoiceRoutes(fastify: FastifyInstance) { total: {type: 'number'}, paid: {type: 'number'}, outstanding: {type: 'number'}, - overdue: {type: 'number'}, - }, - }, - }, - }, - }, - }, + overdue: {type: 'number'} + } + } + } + } + } + } }, invoiceController.getStats.bind(invoiceController) ); @@ -111,11 +111,11 @@ export async function invoiceRoutes(fastify: FastifyInstance) { description: 'List of overdue invoices', type: 'object', properties: { - invoices: {type: 'array', items: {type: 'object'}}, - }, - }, - }, - }, + invoices: {type: 'array', items: {type: 'object'}} + } + } + } + } }, invoiceController.getOverdue.bind(invoiceController) ); @@ -133,8 +133,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 200: { @@ -155,13 +155,13 @@ export async function invoiceRoutes(fastify: FastifyInstance) { notes: {type: 'string', nullable: true}, terms: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } }, invoiceController.getOne.bind(invoiceController) ); @@ -193,24 +193,24 @@ export async function invoiceRoutes(fastify: FastifyInstance) { description: {type: 'string', minLength: 1}, quantity: {type: 'number', minimum: 1}, unitPrice: {type: 'number', minimum: 0}, - amount: {type: 'number', minimum: 0}, - }, - }, + amount: {type: 'number', minimum: 0} + } + } }, notes: {type: 'string'}, - terms: {type: 'string'}, - }, + terms: {type: 'string'} + } }, response: { 201: { description: 'Invoice created successfully', type: 'object', properties: { - invoice: {type: 'object'}, - }, - }, - }, - }, + invoice: {type: 'object'} + } + } + } + } }, invoiceController.create.bind(invoiceController) ); @@ -228,8 +228,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, body: { type: 'object', @@ -246,24 +246,24 @@ export async function invoiceRoutes(fastify: FastifyInstance) { description: {type: 'string', minLength: 1}, quantity: {type: 'number', minimum: 1}, unitPrice: {type: 'number', minimum: 0}, - amount: {type: 'number', minimum: 0}, - }, - }, + amount: {type: 'number', minimum: 0} + } + } }, notes: {type: 'string'}, - terms: {type: 'string'}, - }, + terms: {type: 'string'} + } }, response: { 200: { description: 'Invoice updated successfully', type: 'object', properties: { - invoice: {type: 'object'}, - }, - }, - }, - }, + invoice: {type: 'object'} + } + } + } + } }, invoiceController.update.bind(invoiceController) ); @@ -281,8 +281,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, body: { type: 'object', @@ -290,20 +290,20 @@ export async function invoiceRoutes(fastify: FastifyInstance) { properties: { status: { type: 'string', - enum: ['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED'], - }, - }, + enum: ['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED'] + } + } }, response: { 200: { description: 'Invoice status updated successfully', type: 'object', properties: { - invoice: {type: 'object'}, - }, - }, - }, - }, + invoice: {type: 'object'} + } + } + } + } }, invoiceController.updateStatus.bind(invoiceController) ); @@ -321,16 +321,16 @@ export async function invoiceRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 204: { description: 'Invoice deleted successfully', - type: 'null', - }, - }, - }, + type: 'null' + } + } + } }, invoiceController.delete.bind(invoiceController) ); diff --git a/backend-api/src/routes/liability.routes.ts b/backend-api/src/routes/liability.routes.ts index b426cc8..8ae836b 100644 --- a/backend-api/src/routes/liability.routes.ts +++ b/backend-api/src/routes/liability.routes.ts @@ -42,14 +42,14 @@ export async function liabilityRoutes(fastify: FastifyInstance) { creditor: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } + } }, liabilityController.getAll.bind(liabilityController) ); @@ -69,11 +69,11 @@ export async function liabilityRoutes(fastify: FastifyInstance) { description: 'Total liability value', type: 'object', properties: { - totalValue: {type: 'number'}, - }, - }, - }, - }, + totalValue: {type: 'number'} + } + } + } + } }, liabilityController.getTotalValue.bind(liabilityController) ); @@ -97,13 +97,13 @@ export async function liabilityRoutes(fastify: FastifyInstance) { type: 'object', additionalProperties: { type: 'array', - items: {type: 'object'}, - }, - }, - }, - }, - }, - }, + items: {type: 'object'} + } + } + } + } + } + } }, liabilityController.getByType.bind(liabilityController) ); @@ -121,8 +121,8 @@ export async function liabilityRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 200: { @@ -142,13 +142,13 @@ export async function liabilityRoutes(fastify: FastifyInstance) { creditor: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true}, createdAt: {type: 'string'}, - updatedAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, + updatedAt: {type: 'string'} + } + } + } + } + } + } }, liabilityController.getOne.bind(liabilityController) ); @@ -174,19 +174,19 @@ export async function liabilityRoutes(fastify: FastifyInstance) { minimumPayment: {type: 'number', minimum: 0}, dueDate: {type: 'string', format: 'date-time'}, creditor: {type: 'string', maxLength: 255}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 201: { description: 'Liability created successfully', type: 'object', properties: { - liability: {type: 'object'}, - }, - }, - }, - }, + liability: {type: 'object'} + } + } + } + } }, liabilityController.create.bind(liabilityController) ); @@ -204,8 +204,8 @@ export async function liabilityRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, body: { type: 'object', @@ -217,19 +217,19 @@ export async function liabilityRoutes(fastify: FastifyInstance) { minimumPayment: {type: 'number', minimum: 0}, dueDate: {type: 'string', format: 'date-time'}, creditor: {type: 'string', maxLength: 255}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 200: { description: 'Liability updated successfully', type: 'object', properties: { - liability: {type: 'object'}, - }, - }, - }, - }, + liability: {type: 'object'} + } + } + } + } }, liabilityController.update.bind(liabilityController) ); @@ -247,16 +247,16 @@ export async function liabilityRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 204: { description: 'Liability deleted successfully', - type: 'null', - }, - }, - }, + type: 'null' + } + } + } }, liabilityController.delete.bind(liabilityController) ); diff --git a/backend-api/src/routes/networth.routes.ts b/backend-api/src/routes/networth.routes.ts index fad0147..91d80cd 100644 --- a/backend-api/src/routes/networth.routes.ts +++ b/backend-api/src/routes/networth.routes.ts @@ -35,11 +35,11 @@ export async function netWorthRoutes(fastify: FastifyInstance) { totalLiabilities: {type: 'number'}, netWorth: {type: 'number'}, asOf: {type: 'string'}, - isCalculated: {type: 'boolean'}, - }, - }, - }, - }, + isCalculated: {type: 'boolean'} + } + } + } + } }, netWorthController.getCurrent.bind(netWorthController) ); @@ -70,14 +70,14 @@ export async function netWorthRoutes(fastify: FastifyInstance) { totalLiabilities: {type: 'number'}, netWorth: {type: 'number'}, notes: {type: 'string', nullable: true}, - createdAt: {type: 'string'}, - }, - }, - }, - }, - }, - }, - }, + createdAt: {type: 'string'} + } + } + } + } + } + } + } }, netWorthController.getAllSnapshots.bind(netWorthController) ); @@ -97,19 +97,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) { required: ['startDate', 'endDate'], properties: { startDate: {type: 'string', format: 'date-time'}, - endDate: {type: 'string', format: 'date-time'}, - }, + endDate: {type: 'string', format: 'date-time'} + } }, response: { 200: { description: 'Snapshots in date range', type: 'object', properties: { - snapshots: {type: 'array', items: {type: 'object'}}, - }, - }, - }, - }, + snapshots: {type: 'array', items: {type: 'object'}} + } + } + } + } }, netWorthController.getByDateRange.bind(netWorthController) ); @@ -127,8 +127,8 @@ export async function netWorthRoutes(fastify: FastifyInstance) { querystring: { type: 'object', properties: { - limit: {type: 'string', description: 'Number of periods to include (default: 12)'}, - }, + limit: {type: 'string', description: 'Number of periods to include (default: 12)'} + } }, response: { 200: { @@ -143,14 +143,14 @@ export async function netWorthRoutes(fastify: FastifyInstance) { date: {type: 'string'}, netWorth: {type: 'number'}, growthAmount: {type: 'number'}, - growthPercent: {type: 'number'}, - }, - }, - }, - }, - }, - }, - }, + growthPercent: {type: 'number'} + } + } + } + } + } + } + } }, netWorthController.getGrowthStats.bind(netWorthController) ); @@ -168,19 +168,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 200: { description: 'Snapshot details', type: 'object', properties: { - snapshot: {type: 'object'}, - }, - }, - }, - }, + snapshot: {type: 'object'} + } + } + } + } }, netWorthController.getOne.bind(netWorthController) ); @@ -203,19 +203,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) { totalAssets: {type: 'number', minimum: 0}, totalLiabilities: {type: 'number', minimum: 0}, netWorth: {type: 'number'}, - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 201: { description: 'Snapshot created successfully', type: 'object', properties: { - snapshot: {type: 'object'}, - }, - }, - }, - }, + snapshot: {type: 'object'} + } + } + } + } }, netWorthController.createSnapshot.bind(netWorthController) ); @@ -233,19 +233,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) { body: { type: 'object', properties: { - notes: {type: 'string'}, - }, + notes: {type: 'string'} + } }, response: { 201: { description: 'Snapshot created successfully', type: 'object', properties: { - snapshot: {type: 'object'}, - }, - }, - }, - }, + snapshot: {type: 'object'} + } + } + } + } }, netWorthController.createFromCurrent.bind(netWorthController) ); @@ -263,16 +263,16 @@ export async function netWorthRoutes(fastify: FastifyInstance) { params: { type: 'object', properties: { - id: {type: 'string'}, - }, + id: {type: 'string'} + } }, response: { 204: { description: 'Snapshot deleted successfully', - type: 'null', - }, - }, - }, + type: 'null' + } + } + } }, netWorthController.delete.bind(netWorthController) ); diff --git a/backend-api/src/server.ts b/backend-api/src/server.ts index 017e561..7dd305b 100644 --- a/backend-api/src/server.ts +++ b/backend-api/src/server.ts @@ -20,24 +20,28 @@ import {dashboardRoutes} from './routes/dashboard.routes'; * Implements Single Responsibility: Server configuration */ export async function buildServer() { + if (env.NODE_ENV !== 'production') { + console.log('Development mode enabled. Environment variables [%o]', env); + } + const fastify = Fastify({ logger: { level: env.NODE_ENV === 'development' ? 'info' : 'error', - transport: env.NODE_ENV === 'development' ? {target: 'pino-pretty'} : undefined, - }, + transport: env.NODE_ENV === 'development' ? {target: 'pino-pretty'} : undefined + } }); // Register plugins await fastify.register(cors, { origin: env.CORS_ORIGIN, - credentials: true, + credentials: true }); await fastify.register(jwt, { secret: env.JWT_SECRET, sign: { - expiresIn: env.JWT_EXPIRES_IN, - }, + expiresIn: env.JWT_EXPIRES_IN + } }); // Register Swagger for API documentation @@ -46,32 +50,32 @@ export async function buildServer() { info: { title: 'Personal Finances API', description: 'API for managing personal finances including assets, liabilities, invoices, and more', - version: '1.0.0', + version: '1.0.0' }, servers: [ { url: `http://localhost:${env.PORT}`, - description: 'Development server', - }, + description: 'Development server' + } ], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', - bearerFormat: 'JWT', - }, - }, - }, - }, + bearerFormat: 'JWT' + } + } + } + } }); await fastify.register(swaggerUi, { routePrefix: '/docs', uiConfig: { docExpansion: 'list', - deepLinking: false, - }, + deepLinking: false + } }); // Register error handler @@ -80,7 +84,7 @@ export async function buildServer() { // Health check fastify.get('/health', async () => ({ status: 'ok', - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() })); // Register routes diff --git a/backend-api/src/services/AssetService.ts b/backend-api/src/services/AssetService.ts index 420119f..e5a2fe9 100644 --- a/backend-api/src/services/AssetService.ts +++ b/backend-api/src/services/AssetService.ts @@ -44,7 +44,7 @@ export class AssetService { name: data.name, type: data.type, value: data.value, - user: {connect: {id: userId}}, + user: {connect: {id: userId}} }); } @@ -58,7 +58,7 @@ export class AssetService { this.validateAssetData({ name: data.name || asset.name, type: (data.type || asset.type) as AssetType, - value: data.value !== undefined ? data.value : asset.value, + value: data.value !== undefined ? data.value : asset.value }); } @@ -80,14 +80,17 @@ export class AssetService { async getByType(userId: string): Promise> { const assets = await this.assetRepository.findAllByUser(userId); - return assets.reduce((acc, asset) => { - const type = asset.type; - if (!acc[type]) { - acc[type] = []; - } - acc[type].push(asset); - return acc; - }, {} as Record); + return assets.reduce( + (acc, asset) => { + const type = asset.type; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(asset); + return acc; + }, + {} as Record + ); } private validateAssetData(data: CreateAssetDTO): void { diff --git a/backend-api/src/services/AuthService.ts b/backend-api/src/services/AuthService.ts index 4baeaab..e6ce55f 100644 --- a/backend-api/src/services/AuthService.ts +++ b/backend-api/src/services/AuthService.ts @@ -33,7 +33,7 @@ export class AuthService { const user = await this.userRepository.create({ email, password: hashedPassword, - name, + name }); // Create default debt categories for new user diff --git a/backend-api/src/services/CashflowService.ts b/backend-api/src/services/CashflowService.ts index 61c3051..c17d579 100644 --- a/backend-api/src/services/CashflowService.ts +++ b/backend-api/src/services/CashflowService.ts @@ -1,9 +1,5 @@ import {IncomeSource, Expense, Transaction} from '@prisma/client'; -import { - IncomeSourceRepository, - ExpenseRepository, - TransactionRepository, -} from '../repositories/CashflowRepository'; +import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository'; import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; export interface CreateIncomeSourceDTO { @@ -47,7 +43,7 @@ export class CashflowService { return this.incomeRepository.create({ ...data, - user: {connect: {id: userId}}, + user: {connect: {id: userId}} }); } @@ -85,7 +81,7 @@ export class CashflowService { return this.expenseRepository.create({ ...data, - user: {connect: {id: userId}}, + user: {connect: {id: userId}} }); } @@ -128,7 +124,7 @@ export class CashflowService { return this.transactionRepository.create({ ...data, - user: {connect: {id: userId}}, + user: {connect: {id: userId}} }); } diff --git a/backend-api/src/services/ClientService.ts b/backend-api/src/services/ClientService.ts index 760b643..55f5f14 100644 --- a/backend-api/src/services/ClientService.ts +++ b/backend-api/src/services/ClientService.ts @@ -45,8 +45,8 @@ export class ClientService { address: data.address, notes: data.notes, user: { - connect: {id: userId}, - }, + connect: {id: userId} + } }); } diff --git a/backend-api/src/services/DashboardService.ts b/backend-api/src/services/DashboardService.ts index 96c4735..2554f59 100644 --- a/backend-api/src/services/DashboardService.ts +++ b/backend-api/src/services/DashboardService.ts @@ -48,18 +48,14 @@ export class DashboardService { // Get recent transactions (last 30 days) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const recentCashflow = await this.transactionRepository.getCashflowSummary( - userId, - thirtyDaysAgo, - new Date() - ); + const recentCashflow = await this.transactionRepository.getCashflowSummary(userId, thirtyDaysAgo, new Date()); // Get assets by type const assetsByType = await this.assetRepository.getByType(userId); const assetAllocation = Object.entries(assetsByType).map(([type, assets]) => ({ type, count: assets.length, - totalValue: assets.reduce((sum, asset) => sum + asset.currentValue, 0), + totalValue: assets.reduce((sum, asset) => sum + asset.currentValue, 0) })); return { @@ -68,29 +64,29 @@ export class DashboardService { assets: totalAssets, liabilities: totalLiabilities, change: netWorthChange, - lastUpdated: new Date(), + lastUpdated: new Date() }, invoices: { total: invoiceStats.totalInvoices, paid: invoiceStats.paidInvoices, outstanding: invoiceStats.outstandingAmount, - overdue: invoiceStats.overdueInvoices, + overdue: invoiceStats.overdueInvoices }, debts: { total: totalDebt, - accounts: (await this.debtAccountRepository.findAllByUser(userId)).length, + accounts: (await this.debtAccountRepository.findAllByUser(userId)).length }, cashflow: { monthlyIncome: totalMonthlyIncome, monthlyExpenses: totalMonthlyExpenses, monthlyNet: monthlyCashflow, - last30Days: recentCashflow, + last30Days: recentCashflow }, assets: { total: totalAssets, count: (await this.assetRepository.findAllByUser(userId)).length, - allocation: assetAllocation, - }, + allocation: assetAllocation + } }; } } diff --git a/backend-api/src/services/DebtAccountService.ts b/backend-api/src/services/DebtAccountService.ts index 7c61b23..9571887 100644 --- a/backend-api/src/services/DebtAccountService.ts +++ b/backend-api/src/services/DebtAccountService.ts @@ -64,8 +64,8 @@ export class DebtAccountService { dueDate: data.dueDate, notes: data.notes, category: { - connect: {id: data.categoryId}, - }, + connect: {id: data.categoryId} + } }); } diff --git a/backend-api/src/services/DebtCategoryService.ts b/backend-api/src/services/DebtCategoryService.ts index 42ebc25..1f8f15c 100644 --- a/backend-api/src/services/DebtCategoryService.ts +++ b/backend-api/src/services/DebtCategoryService.ts @@ -32,7 +32,7 @@ export class DebtCategoryService { {name: 'Auto Loans', description: 'Car and vehicle loans', color: '#10b981'}, {name: 'Mortgages', description: 'Home mortgages', color: '#f59e0b'}, {name: 'Personal Loans', description: 'Personal loan debts', color: '#8b5cf6'}, - {name: 'Other', description: 'Other debt types', color: '#6b7280'}, + {name: 'Other', description: 'Other debt types', color: '#6b7280'} ]; const categories: DebtCategory[] = []; @@ -43,8 +43,8 @@ export class DebtCategoryService { description: category.description, color: category.color, user: { - connect: {id: userId}, - }, + connect: {id: userId} + } }); categories.push(created); } @@ -69,8 +69,8 @@ export class DebtCategoryService { description: data.description, color: data.color, user: { - connect: {id: userId}, - }, + connect: {id: userId} + } }); } diff --git a/backend-api/src/services/DebtPaymentService.ts b/backend-api/src/services/DebtPaymentService.ts index fde1320..40971d8 100644 --- a/backend-api/src/services/DebtPaymentService.ts +++ b/backend-api/src/services/DebtPaymentService.ts @@ -42,14 +42,14 @@ export class DebtPaymentService { paymentDate: data.paymentDate, notes: data.notes, account: { - connect: {id: data.accountId}, - }, + connect: {id: data.accountId} + } }); // Update account current balance const newBalance = account.currentBalance - data.amount; await this.accountRepository.update(data.accountId, { - currentBalance: Math.max(0, newBalance), // Don't allow negative balance + currentBalance: Math.max(0, newBalance) // Don't allow negative balance }); return payment; @@ -114,7 +114,7 @@ export class DebtPaymentService { if (account) { const newBalance = account.currentBalance + payment.amount; await this.accountRepository.update(payment.accountId, { - currentBalance: newBalance, + currentBalance: newBalance }); } diff --git a/backend-api/src/services/InvoiceService.ts b/backend-api/src/services/InvoiceService.ts index d83b909..8123f89 100644 --- a/backend-api/src/services/InvoiceService.ts +++ b/backend-api/src/services/InvoiceService.ts @@ -66,8 +66,7 @@ export class InvoiceService { this.validateInvoiceData(data); // Generate invoice number if not provided - const invoiceNumber = - data.invoiceNumber || (await this.invoiceRepository.generateInvoiceNumber(userId)); + const invoiceNumber = data.invoiceNumber || (await this.invoiceRepository.generateInvoiceNumber(userId)); // Check if invoice number already exists const exists = await this.invoiceRepository.invoiceNumberExists(userId, invoiceNumber); @@ -80,7 +79,7 @@ export class InvoiceService { description: item.description, quantity: item.quantity, unitPrice: item.unitPrice, - total: item.quantity * item.unitPrice, + total: item.quantity * item.unitPrice })); const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0); @@ -99,8 +98,8 @@ export class InvoiceService { user: {connect: {id: userId}}, client: {connect: {id: data.clientId}}, lineItems: { - create: lineItems, - }, + create: lineItems + } }); } @@ -127,7 +126,7 @@ export class InvoiceService { description: item.description, quantity: item.quantity, unitPrice: item.unitPrice, - total: item.quantity * item.unitPrice, + total: item.quantity * item.unitPrice })); const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0); @@ -137,7 +136,7 @@ export class InvoiceService { updateData.total = total; updateData.lineItems = { deleteMany: {}, - create: lineItems, + create: lineItems }; } @@ -155,7 +154,7 @@ export class InvoiceService { async getStats(userId: string): Promise { const invoices = await this.invoiceRepository.findAllByUser(userId); - + const stats: InvoiceStats = { total: invoices.length, draft: 0, @@ -164,12 +163,12 @@ export class InvoiceService { overdue: 0, totalAmount: 0, paidAmount: 0, - outstandingAmount: 0, + outstandingAmount: 0 }; for (const inv of invoices) { stats.totalAmount += inv.total; - + switch (inv.status) { case 'draft': stats.draft++; diff --git a/backend-api/src/services/LiabilityService.ts b/backend-api/src/services/LiabilityService.ts index 95859b4..214acff 100644 --- a/backend-api/src/services/LiabilityService.ts +++ b/backend-api/src/services/LiabilityService.ts @@ -48,8 +48,8 @@ export class LiabilityService { creditor: data.creditor, notes: data.notes, user: { - connect: {id: userId}, - }, + connect: {id: userId} + } }); } diff --git a/backend-api/src/services/NetWorthService.ts b/backend-api/src/services/NetWorthService.ts index f45da73..4cebfe8 100644 --- a/backend-api/src/services/NetWorthService.ts +++ b/backend-api/src/services/NetWorthService.ts @@ -40,9 +40,7 @@ export class NetWorthService { const calculatedNetWorth = data.totalAssets - data.totalLiabilities; if (Math.abs(calculatedNetWorth - data.netWorth) > 0.01) { // Allow small floating point differences - throw new ValidationError( - `Net worth calculation mismatch. Expected ${calculatedNetWorth}, got ${data.netWorth}` - ); + throw new ValidationError(`Net worth calculation mismatch. Expected ${calculatedNetWorth}, got ${data.netWorth}`); } return this.snapshotRepository.create({ @@ -52,8 +50,8 @@ export class NetWorthService { netWorth: data.netWorth, notes: data.notes, user: { - connect: {id: userId}, - }, + connect: {id: userId} + } }); } @@ -70,7 +68,7 @@ export class NetWorthService { totalAssets, totalLiabilities, netWorth, - notes, + notes }); } @@ -84,11 +82,7 @@ export class NetWorthService { /** * Get snapshots within a date range */ - async getSnapshotsByDateRange( - userId: string, - startDate: Date, - endDate: Date - ): Promise { + async getSnapshotsByDateRange(userId: string, startDate: Date, endDate: Date): Promise { return this.snapshotRepository.getByDateRange(userId, startDate, endDate); } @@ -106,8 +100,7 @@ export class NetWorthService { // If we have a recent snapshot (within last 24 hours), use it if (latestSnapshot) { - const hoursSinceSnapshot = - (Date.now() - latestSnapshot.date.getTime()) / (1000 * 60 * 60); + const hoursSinceSnapshot = (Date.now() - latestSnapshot.date.getTime()) / (1000 * 60 * 60); if (hoursSinceSnapshot < 24) { return { @@ -115,7 +108,7 @@ export class NetWorthService { totalLiabilities: latestSnapshot.totalLiabilities, netWorth: latestSnapshot.netWorth, asOf: latestSnapshot.date, - isCalculated: false, + isCalculated: false }; } } @@ -129,7 +122,7 @@ export class NetWorthService { totalLiabilities, netWorth: totalAssets - totalLiabilities, asOf: new Date(), - isCalculated: true, + isCalculated: true }; } diff --git a/backend-api/src/types/fastify.d.ts b/backend-api/src/types/fastify.d.ts index 80e3c40..da2e37a 100644 --- a/backend-api/src/types/fastify.d.ts +++ b/backend-api/src/types/fastify.d.ts @@ -12,4 +12,3 @@ declare module '@fastify/jwt' { }; } } - diff --git a/backend-api/src/utils/password.ts b/backend-api/src/utils/password.ts index 74ab504..3935570 100644 --- a/backend-api/src/utils/password.ts +++ b/backend-api/src/utils/password.ts @@ -33,7 +33,7 @@ export class PasswordService { return { valid: errors.length === 0, - errors, + errors }; } } diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..038f5ba --- /dev/null +++ b/bun.lock @@ -0,0 +1,355 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "personal-finances", + "dependencies": { + "concurrently": "^9.2.1", + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript-eslint": "^8.46.4", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], + + "@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], + + "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + + "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], + + "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.49.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/type-utils": "8.49.0", "@typescript-eslint/utils": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.49.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.49.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.49.0", "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.49.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.49.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.49.0", "@typescript-eslint/tsconfig-utils": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.49.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.6", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001760", "", {}, "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.49.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.49.0", "@typescript-eslint/parser": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + } +} diff --git a/frontend-web/.env.example b/frontend-web/.env.example new file mode 100644 index 0000000..5317fce --- /dev/null +++ b/frontend-web/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:3000 diff --git a/frontend-web/.gitignore b/frontend-web/.gitignore index a547bf3..d82c70b 100644 --- a/frontend-web/.gitignore +++ b/frontend-web/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Environment variables +.env +.env.local + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 90212b4..fe10fbe 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -1,6 +1,9 @@ -import {lazy, Suspense} from 'react'; +import {lazy, Suspense, useEffect} from 'react'; import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom'; -import {useAppSelector} from '@/store'; +import {useAppSelector, useAppDispatch} from '@/store'; +import {loadUserFromStorage} from '@/store/slices/userSlice'; +import {fetchAssets, fetchLiabilities, fetchSnapshots} from '@/store/slices/netWorthSlice'; +import {fetchIncomeSources, fetchExpenses, fetchTransactions} from '@/store/slices/cashflowSlice'; import Layout from '@/components/Layout'; import ProtectedRoute from '@/components/ProtectedRoute'; @@ -20,7 +23,30 @@ const PageLoader = () => ( ); function AppRoutes() { - const {isAuthenticated} = useAppSelector(state => state.user); + const dispatch = useAppDispatch(); + const {isAuthenticated, isLoading} = useAppSelector(state => state.user); + + // Load user from storage on app start + useEffect(() => { + dispatch(loadUserFromStorage()); + }, [dispatch]); + + // Fetch all data when user is authenticated + useEffect(() => { + if (isAuthenticated) { + dispatch(fetchAssets()); + dispatch(fetchLiabilities()); + dispatch(fetchSnapshots()); + dispatch(fetchIncomeSources()); + dispatch(fetchExpenses()); + dispatch(fetchTransactions()); + } + }, [isAuthenticated, dispatch]); + + // Show loading while checking authentication + if (isLoading) { + return ; + } return ( diff --git a/frontend-web/src/components/dialogs/AddAssetDialog.tsx b/frontend-web/src/components/dialogs/AddAssetDialog.tsx index 2d05a00..40766de 100644 --- a/frontend-web/src/components/dialogs/AddAssetDialog.tsx +++ b/frontend-web/src/components/dialogs/AddAssetDialog.tsx @@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import {useAppDispatch, addAsset} from '@/store'; +import {useAppDispatch, createAsset} from '@/store'; import {validatePositiveNumber, validateRequired, sanitizeString} from '@/lib/validation'; interface Props { @@ -46,12 +46,10 @@ export default function AddAssetDialog({open, onOpenChange}: Props) { if (valueNum === null) return; dispatch( - addAsset({ - id: crypto.randomUUID(), + createAsset({ name: sanitizeString(form.name), - type: form.type as (typeof assetTypes)[number], + type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER', value: valueNum, - updatedAt: new Date().toISOString() }) ); onOpenChange(false); diff --git a/frontend-web/src/components/dialogs/AddLiabilityDialog.tsx b/frontend-web/src/components/dialogs/AddLiabilityDialog.tsx index ec7d14d..cf235b5 100644 --- a/frontend-web/src/components/dialogs/AddLiabilityDialog.tsx +++ b/frontend-web/src/components/dialogs/AddLiabilityDialog.tsx @@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import {useAppDispatch, addLiability} from '@/store'; +import {useAppDispatch, createLiability} from '@/store'; interface Props { open: boolean; @@ -20,12 +20,10 @@ export default function AddLiabilityDialog({open, onOpenChange}: Props) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); dispatch( - addLiability({ - id: crypto.randomUUID(), + createLiability({ name: form.name, - type: form.type as (typeof liabilityTypes)[number], - balance: parseFloat(form.balance) || 0, - updatedAt: new Date().toISOString() + type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER', + currentBalance: parseFloat(form.balance) || 0, }) ); onOpenChange(false); diff --git a/frontend-web/src/components/dialogs/EditAssetDialog.tsx b/frontend-web/src/components/dialogs/EditAssetDialog.tsx index dbcc45c..6be6307 100644 --- a/frontend-web/src/components/dialogs/EditAssetDialog.tsx +++ b/frontend-web/src/components/dialogs/EditAssetDialog.tsx @@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import {useAppDispatch, updateAsset, removeAsset, type Asset} from '@/store'; +import {useAppDispatch, updateAsset, deleteAsset, type Asset} from '@/store'; import {validatePositiveNumber, validateRequired} from '@/lib/validation'; interface Props { @@ -60,10 +60,11 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) { dispatch( updateAsset({ id: asset.id, - name: form.name.trim(), - type: form.type as (typeof assetTypes)[number], - value: valueNum, - updatedAt: new Date().toISOString() + data: { + name: form.name.trim(), + type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER', + value: valueNum, + } }) ); onOpenChange(false); @@ -72,7 +73,7 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) { const handleDelete = () => { if (!asset) return; if (confirm(`Are you sure you want to delete "${asset.name}"?`)) { - dispatch(removeAsset(asset.id)); + dispatch(deleteAsset(asset.id)); onOpenChange(false); } }; diff --git a/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx b/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx index 404b045..a914971 100644 --- a/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx +++ b/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx @@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import {useAppDispatch, updateLiability, removeLiability, type Liability} from '@/store'; +import {useAppDispatch, updateLiability, deleteLiability, type Liability} from '@/store'; import {validatePositiveNumber, validateRequired} from '@/lib/validation'; interface Props { @@ -60,10 +60,11 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro dispatch( updateLiability({ id: liability.id, - name: form.name.trim(), - type: form.type as (typeof liabilityTypes)[number], - balance: balanceNum, - updatedAt: new Date().toISOString() + data: { + name: form.name.trim(), + type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER', + currentBalance: balanceNum, + } }) ); onOpenChange(false); @@ -72,7 +73,7 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro const handleDelete = () => { if (!liability) return; if (confirm(`Are you sure you want to delete "${liability.name}"?`)) { - dispatch(removeLiability(liability.id)); + dispatch(deleteLiability(liability.id)); onOpenChange(false); } }; diff --git a/frontend-web/src/components/dialogs/LoginDialog.tsx b/frontend-web/src/components/dialogs/LoginDialog.tsx index 23c89a5..e1a4de5 100644 --- a/frontend-web/src/components/dialogs/LoginDialog.tsx +++ b/frontend-web/src/components/dialogs/LoginDialog.tsx @@ -3,7 +3,8 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import {useAppDispatch, setUser} from '@/store'; +import {useAppDispatch} from '@/store'; +import {loginUser} from '@/store/slices/userSlice'; interface Props { open: boolean; @@ -18,26 +19,33 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop password: '', }); const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); - // Mock login - in production this would validate against an API if (!form.email || !form.password) { setError('Please enter your email and password'); return; } - // Mock successful login - dispatch(setUser({ - id: crypto.randomUUID(), - email: form.email, - name: form.email.split('@')[0], - })); + setIsLoading(true); + try { + await dispatch( + loginUser({ + email: form.email, + password: form.password, + }) + ).unwrap(); - onOpenChange(false); - setForm({email: '', password: ''}); + onOpenChange(false); + setForm({email: '', password: ''}); + } catch (err: any) { + setError(err || 'Login failed. Please check your credentials.'); + } finally { + setIsLoading(false); + } }; return ( @@ -76,8 +84,10 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop {error &&

{error}

} - - + diff --git a/frontend-web/src/components/dialogs/SignUpDialog.tsx b/frontend-web/src/components/dialogs/SignUpDialog.tsx index c4f7fe6..25745ca 100644 --- a/frontend-web/src/components/dialogs/SignUpDialog.tsx +++ b/frontend-web/src/components/dialogs/SignUpDialog.tsx @@ -3,7 +3,8 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import {useAppDispatch, setUser} from '@/store'; +import {useAppDispatch} from '@/store'; +import {registerUser} from '@/store/slices/userSlice'; interface Props { open: boolean; @@ -20,8 +21,9 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop confirmPassword: '', }); const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -30,20 +32,28 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop return; } - if (form.password.length < 6) { - setError('Password must be at least 6 characters'); + if (form.password.length < 8) { + setError('Password must be at least 8 characters'); return; } - // Mock sign up - in production this would call an API - dispatch(setUser({ - id: crypto.randomUUID(), - email: form.email, - name: form.name, - })); + setIsLoading(true); + try { + await dispatch( + registerUser({ + email: form.email, + password: form.password, + name: form.name, + }) + ).unwrap(); - onOpenChange(false); - setForm({name: '', email: '', password: '', confirmPassword: ''}); + onOpenChange(false); + setForm({name: '', email: '', password: '', confirmPassword: ''}); + } catch (err: any) { + setError(err || 'Registration failed. Please try again.'); + } finally { + setIsLoading(false); + } }; return ( @@ -109,8 +119,10 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop

- - + diff --git a/frontend-web/src/lib/api/auth.service.ts b/frontend-web/src/lib/api/auth.service.ts new file mode 100644 index 0000000..78ef059 --- /dev/null +++ b/frontend-web/src/lib/api/auth.service.ts @@ -0,0 +1,71 @@ +/** + * Authentication Service + */ + +import {apiClient} from './client'; +import {tokenStorage} from './token'; + +export interface RegisterRequest { + email: string; + password: string; + name: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface AuthResponse { + token: string; + user: { + id: string; + email: string; + name: string; + }; +} + +export interface UserProfile { + id: string; + email: string; + name: string; + createdAt: string; +} + +export const authService = { + async register(data: RegisterRequest): Promise { + const response = await apiClient.post('/register', data); + tokenStorage.setToken(response.token); + tokenStorage.setUser(JSON.stringify(response.user)); + return response; + }, + + async login(data: LoginRequest): Promise { + const response = await apiClient.post('/login', data); + tokenStorage.setToken(response.token); + tokenStorage.setUser(JSON.stringify(response.user)); + return response; + }, + + async getProfile(): Promise { + return apiClient.get('/profile'); + }, + + logout(): void { + tokenStorage.clear(); + }, + + isAuthenticated(): boolean { + return !!tokenStorage.getToken(); + }, + + getCurrentUser(): {id: string; email: string; name: string} | null { + const userStr = tokenStorage.getUser(); + if (!userStr) return null; + try { + return JSON.parse(userStr); + } catch { + return null; + } + }, +}; diff --git a/frontend-web/src/lib/api/cashflow.service.ts b/frontend-web/src/lib/api/cashflow.service.ts new file mode 100644 index 0000000..3c4f1cd --- /dev/null +++ b/frontend-web/src/lib/api/cashflow.service.ts @@ -0,0 +1,88 @@ +/** + * Cashflow Service + */ + +import {apiClient} from './client'; + +export interface IncomeSource { + id: string; + name: string; + amount: number; + frequency: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY' | 'ONCE'; + notes?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface Expense { + id: string; + name: string; + amount: number; + frequency: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY' | 'ONCE'; + category?: string; + isEssential?: boolean; + notes?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface Transaction { + id: string; + type: 'INCOME' | 'EXPENSE'; + amount: number; + description: string; + category?: string; + date: string; + notes?: string; + createdAt?: string; +} + +export const incomeService = { + async getAll(): Promise<{incomeSources: IncomeSource[]}> { + return apiClient.get<{incomeSources: IncomeSource[]}>('/cashflow/income'); + }, + + async create(data: Partial): Promise<{incomeSource: IncomeSource}> { + return apiClient.post<{incomeSource: IncomeSource}>('/cashflow/income', data); + }, + + async update(id: string, data: Partial): Promise<{incomeSource: IncomeSource}> { + return apiClient.put<{incomeSource: IncomeSource}>(`/cashflow/income/${id}`, data); + }, + + async delete(id: string): Promise { + return apiClient.delete(`/cashflow/income/${id}`); + }, +}; + +export const expenseService = { + async getAll(): Promise<{expenses: Expense[]}> { + return apiClient.get<{expenses: Expense[]}>('/cashflow/expenses'); + }, + + async create(data: Partial): Promise<{expense: Expense}> { + return apiClient.post<{expense: Expense}>('/cashflow/expenses', data); + }, + + async update(id: string, data: Partial): Promise<{expense: Expense}> { + return apiClient.put<{expense: Expense}>(`/cashflow/expenses/${id}`, data); + }, + + async delete(id: string): Promise { + return apiClient.delete(`/cashflow/expenses/${id}`); + }, +}; + +export const transactionService = { + async getAll(): Promise<{transactions: Transaction[]}> { + return apiClient.get<{transactions: Transaction[]}>('/cashflow/transactions'); + }, + + async create(data: Partial): Promise<{transaction: Transaction}> { + return apiClient.post<{transaction: Transaction}>('/cashflow/transactions', data); + }, + + async delete(id: string): Promise { + return apiClient.delete(`/cashflow/transactions/${id}`); + }, +}; diff --git a/frontend-web/src/lib/api/client.ts b/frontend-web/src/lib/api/client.ts new file mode 100644 index 0000000..0a8f548 --- /dev/null +++ b/frontend-web/src/lib/api/client.ts @@ -0,0 +1,110 @@ +/** + * API Client + * Base configuration for all API requests + */ + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + +export interface ApiError { + message: string; + statusCode: number; + error?: string; +} + +class ApiClient { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + private getAuthToken(): string | null { + return localStorage.getItem('auth_token'); + } + + private async request(endpoint: string, options: RequestInit = {}): Promise { + const url = `${this.baseURL}${endpoint}`; + const token = this.getAuthToken(); + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + try { + const response = await fetch(url, { + ...options, + headers, + }); + + // Handle non-JSON responses + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + if (!response.ok) { + throw { + message: 'Request failed', + statusCode: response.status, + error: response.statusText, + } as ApiError; + } + return {} as T; + } + + const data = await response.json(); + + if (!response.ok) { + throw { + message: data.message || 'Request failed', + statusCode: response.status, + error: data.error, + } as ApiError; + } + + return data; + } catch (error) { + if ((error as ApiError).statusCode) { + throw error; + } + throw { + message: 'Network error', + statusCode: 0, + error: String(error), + } as ApiError; + } + } + + async get(endpoint: string): Promise { + return this.request(endpoint, {method: 'GET'}); + } + + async post(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async put(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async patch(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PATCH', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise { + return this.request(endpoint, {method: 'DELETE'}); + } +} + +export const apiClient = new ApiClient(API_URL); diff --git a/frontend-web/src/lib/api/networth.service.ts b/frontend-web/src/lib/api/networth.service.ts new file mode 100644 index 0000000..164b657 --- /dev/null +++ b/frontend-web/src/lib/api/networth.service.ts @@ -0,0 +1,144 @@ +/** + * Net Worth Service + */ + +import {apiClient} from './client'; + +export interface Asset { + id: string; + name: string; + type: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER'; + value: number; + createdAt?: string; + updatedAt?: string; +} + +export interface CreateAssetRequest { + name: string; + type: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER'; + value: number; +} + +export interface UpdateAssetRequest { + name?: string; + type?: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER'; + value?: number; +} + +export interface Liability { + id: string; + name: string; + type: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER'; + currentBalance: number; + interestRate?: number; + minimumPayment?: number; + dueDate?: string; + creditor?: string; + notes?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface CreateLiabilityRequest { + name: string; + type: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER'; + currentBalance: number; + interestRate?: number; + minimumPayment?: number; + dueDate?: string; + creditor?: string; + notes?: string; +} + +export interface UpdateLiabilityRequest { + name?: string; + type?: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER'; + currentBalance?: number; + interestRate?: number; + minimumPayment?: number; + dueDate?: string; + creditor?: string; + notes?: string; +} + +export interface NetWorthSnapshot { + id: string; + date: string; + totalAssets: number; + totalLiabilities: number; + netWorth: number; + notes?: string; + createdAt?: string; +} + +export const assetService = { + async getAll(): Promise<{assets: Asset[]}> { + return apiClient.get<{assets: Asset[]}>('/assets'); + }, + + async getById(id: string): Promise<{asset: Asset}> { + return apiClient.get<{asset: Asset}>(`/assets/${id}`); + }, + + async create(data: CreateAssetRequest): Promise<{asset: Asset}> { + return apiClient.post<{asset: Asset}>('/assets', data); + }, + + async update(id: string, data: UpdateAssetRequest): Promise<{asset: Asset}> { + return apiClient.put<{asset: Asset}>(`/assets/${id}`, data); + }, + + async delete(id: string): Promise { + return apiClient.delete(`/assets/${id}`); + }, +}; + +export const liabilityService = { + async getAll(): Promise<{liabilities: Liability[]}> { + return apiClient.get<{liabilities: Liability[]}>('/liabilities'); + }, + + async getById(id: string): Promise<{liability: Liability}> { + return apiClient.get<{liability: Liability}>(`/liabilities/${id}`); + }, + + async create(data: CreateLiabilityRequest): Promise<{liability: Liability}> { + return apiClient.post<{liability: Liability}>('/liabilities', data); + }, + + async update(id: string, data: UpdateLiabilityRequest): Promise<{liability: Liability}> { + return apiClient.put<{liability: Liability}>(`/liabilities/${id}`, data); + }, + + async delete(id: string): Promise { + return apiClient.delete(`/liabilities/${id}`); + }, +}; + +export const snapshotService = { + async getAll(): Promise<{snapshots: NetWorthSnapshot[]}> { + return apiClient.get<{snapshots: NetWorthSnapshot[]}>('/networth/snapshots'); + }, + + async getById(id: string): Promise<{snapshot: NetWorthSnapshot}> { + return apiClient.get<{snapshot: NetWorthSnapshot}>(`/networth/snapshots/${id}`); + }, + + async create(data: { + date: string; + totalAssets: number; + totalLiabilities: number; + netWorth: number; + notes?: string; + }): Promise<{snapshot: NetWorthSnapshot}> { + return apiClient.post<{snapshot: NetWorthSnapshot}>('/networth/snapshots', data); + }, + + async createFromCurrent(notes?: string): Promise<{snapshot: NetWorthSnapshot}> { + return apiClient.post<{snapshot: NetWorthSnapshot}>('/networth/snapshots/record', {notes}); + }, + + async delete(id: string): Promise { + return apiClient.delete(`/networth/snapshots/${id}`); + }, +}; diff --git a/frontend-web/src/lib/api/token.ts b/frontend-web/src/lib/api/token.ts new file mode 100644 index 0000000..b1a9e1d --- /dev/null +++ b/frontend-web/src/lib/api/token.ts @@ -0,0 +1,37 @@ +/** + * Token Storage Utilities + */ + +const TOKEN_KEY = 'auth_token'; +const USER_KEY = 'auth_user'; + +export const tokenStorage = { + getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); + }, + + setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); + }, + + removeToken(): void { + localStorage.removeItem(TOKEN_KEY); + }, + + getUser(): string | null { + return localStorage.getItem(USER_KEY); + }, + + setUser(user: string): void { + localStorage.setItem(USER_KEY, user); + }, + + removeUser(): void { + localStorage.removeItem(USER_KEY); + }, + + clear(): void { + this.removeToken(); + this.removeUser(); + }, +}; diff --git a/frontend-web/src/pages/NetWorthPage.tsx b/frontend-web/src/pages/NetWorthPage.tsx index c86049b..c22f9f1 100644 --- a/frontend-web/src/pages/NetWorthPage.tsx +++ b/frontend-web/src/pages/NetWorthPage.tsx @@ -1,7 +1,7 @@ import {useState} from 'react'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; -import {useAppSelector, useAppDispatch, addSnapshot, type Asset, type Liability} from '@/store'; +import {useAppSelector, type Asset, type Liability} from '@/store'; import {AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer} from 'recharts'; import {format} from 'date-fns'; import AddAssetDialog from '@/components/dialogs/AddAssetDialog'; @@ -12,7 +12,6 @@ import {formatCurrency, formatPercentage} from '@/lib/formatters'; import {calculateMonthlyChange, calculateYTDGrowth} from '@/lib/calculations'; export default function NetWorthPage() { - const dispatch = useAppDispatch(); const [assetDialogOpen, setAssetDialogOpen] = useState(false); const [liabilityDialogOpen, setLiabilityDialogOpen] = useState(false); const [editAssetDialogOpen, setEditAssetDialogOpen] = useState(false); @@ -34,14 +33,8 @@ export default function NetWorthPage() { const ytdGrowth = calculateYTDGrowth(snapshots); const handleRecordSnapshot = () => { - const snapshot = { - id: crypto.randomUUID(), - date: new Date().toISOString().split('T')[0], - totalAssets, - totalLiabilities, - netWorth - }; - dispatch(addSnapshot(snapshot)); + // TODO: Implement createSnapshot thunk + console.log('Record snapshot functionality not yet implemented'); }; const handleEditAsset = (asset: Asset) => { diff --git a/frontend-web/src/store/index.ts b/frontend-web/src/store/index.ts index d8d249c..9c3f8aa 100644 --- a/frontend-web/src/store/index.ts +++ b/frontend-web/src/store/index.ts @@ -13,14 +13,15 @@ export type {User, UserState} from './slices/userSlice'; export { setLoading as setNetWorthLoading, setError as setNetWorthError, - addAsset, + fetchAssets, + createAsset, updateAsset, - removeAsset, - addLiability, + deleteAsset, + fetchLiabilities, + createLiability, updateLiability, - removeLiability, - addSnapshot, - setSnapshots + deleteLiability, + fetchSnapshots } from './slices/netWorthSlice'; export type {Asset, Liability, NetWorthSnapshot, NetWorthState} from './slices/netWorthSlice'; diff --git a/frontend-web/src/store/slices/cashflowSlice.ts b/frontend-web/src/store/slices/cashflowSlice.ts index 8deaca2..fbe5889 100644 --- a/frontend-web/src/store/slices/cashflowSlice.ts +++ b/frontend-web/src/store/slices/cashflowSlice.ts @@ -1,4 +1,5 @@ -import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; +import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit'; +import {incomeService, expenseService, transactionService, type IncomeSource as ApiIncome, type Expense as ApiExpense, type Transaction as ApiTransaction} from '@/lib/api/cashflow.service'; export interface IncomeSource { id: string; @@ -47,162 +48,77 @@ const defaultCategories = { expense: ['Housing', 'Utilities', 'Transportation', 'Food', 'Insurance', 'Healthcare', 'Subscriptions', 'Entertainment', 'Shopping', 'Savings', 'Other'] }; -// Mock data -const mockIncomeSources: IncomeSource[] = [ - { - id: 'i1', - name: 'Software Engineer Salary', - amount: 8500, - frequency: 'monthly', - category: 'Salary', - nextDate: '2024-12-15', - isActive: true, - createdAt: '2024-01-01' - }, - {id: 'i2', name: 'Consulting', amount: 2000, frequency: 'monthly', category: 'Freelance', nextDate: '2024-12-20', isActive: true, createdAt: '2024-03-01'}, - { - id: 'i3', - name: 'Dividend Income', - amount: 450, - frequency: 'quarterly', - category: 'Investments', - nextDate: '2024-12-31', - isActive: true, - createdAt: '2024-01-01' - } -]; - -const mockExpenses: Expense[] = [ - { - id: 'e1', - name: 'Mortgage', - amount: 2200, - frequency: 'monthly', - category: 'Housing', - nextDate: '2024-12-01', - isActive: true, - isEssential: true, - createdAt: '2024-01-01' - }, - { - id: 'e2', - name: 'Car Payment', - amount: 450, - frequency: 'monthly', - category: 'Transportation', - nextDate: '2024-12-05', - isActive: true, - isEssential: true, - createdAt: '2024-01-01' - }, - { - id: 'e3', - name: 'Car Insurance', - amount: 180, - frequency: 'monthly', - category: 'Insurance', - nextDate: '2024-12-10', - isActive: true, - isEssential: true, - createdAt: '2024-01-01' - }, - { - id: 'e4', - name: 'Utilities', - amount: 250, - frequency: 'monthly', - category: 'Utilities', - nextDate: '2024-12-15', - isActive: true, - isEssential: true, - createdAt: '2024-01-01' - }, - { - id: 'e5', - name: 'Groceries', - amount: 600, - frequency: 'monthly', - category: 'Food', - nextDate: '2024-12-01', - isActive: true, - isEssential: true, - createdAt: '2024-01-01' - }, - { - id: 'e6', - name: 'Gym Membership', - amount: 50, - frequency: 'monthly', - category: 'Healthcare', - nextDate: '2024-12-01', - isActive: true, - isEssential: false, - createdAt: '2024-01-01' - }, - { - id: 'e7', - name: 'Netflix', - amount: 15, - frequency: 'monthly', - category: 'Subscriptions', - nextDate: '2024-12-08', - isActive: true, - isEssential: false, - createdAt: '2024-01-01' - }, - { - id: 'e8', - name: 'Spotify', - amount: 12, - frequency: 'monthly', - category: 'Subscriptions', - nextDate: '2024-12-12', - isActive: true, - isEssential: false, - createdAt: '2024-01-01' - }, - { - id: 'e9', - name: 'Health Insurance', - amount: 350, - frequency: 'monthly', - category: 'Insurance', - nextDate: '2024-12-01', - isActive: true, - isEssential: true, - createdAt: '2024-01-01' - }, - { - id: 'e10', - name: '401k Contribution', - amount: 1500, - frequency: 'monthly', - category: 'Savings', - nextDate: '2024-12-15', - isActive: true, - isEssential: true, - createdAt: '2024-01-01' - } -]; - -const mockTransactions: Transaction[] = [ - {id: 't1', type: 'income', name: 'Salary', amount: 8500, category: 'Salary', date: '2024-11-15'}, - {id: 't2', type: 'expense', name: 'Mortgage', amount: 2200, category: 'Housing', date: '2024-11-01'}, - {id: 't3', type: 'expense', name: 'Groceries', amount: 145, category: 'Food', date: '2024-11-28'}, - {id: 't4', type: 'expense', name: 'Gas', amount: 55, category: 'Transportation', date: '2024-11-25'}, - {id: 't5', type: 'income', name: 'Consulting Payment', amount: 2000, category: 'Freelance', date: '2024-11-20'}, - {id: 't6', type: 'expense', name: 'Restaurant', amount: 85, category: 'Food', date: '2024-11-22'} -]; - const initialState: CashflowState = { - incomeSources: mockIncomeSources, - expenses: mockExpenses, - transactions: mockTransactions, + incomeSources: [], + expenses: [], + transactions: [], categories: defaultCategories, isLoading: false, error: null }; +// Helper mappers +const mapApiIncomeToIncome = (apiIncome: ApiIncome): IncomeSource => ({ + id: apiIncome.id, + name: apiIncome.name, + amount: apiIncome.amount, + frequency: apiIncome.frequency.toLowerCase() as IncomeSource['frequency'], + category: 'Income', + nextDate: new Date().toISOString(), + isActive: true, + createdAt: apiIncome.createdAt || new Date().toISOString(), +}); + +const mapApiExpenseToExpense = (apiExpense: ApiExpense): Expense => ({ + id: apiExpense.id, + name: apiExpense.name, + amount: apiExpense.amount, + frequency: apiExpense.frequency.toLowerCase() as Expense['frequency'], + category: apiExpense.category || 'Other', + nextDate: new Date().toISOString(), + isActive: true, + isEssential: apiExpense.isEssential || false, + createdAt: apiExpense.createdAt || new Date().toISOString(), +}); + +const mapApiTransactionToTransaction = (apiTransaction: ApiTransaction): Transaction => ({ + id: apiTransaction.id, + type: apiTransaction.type.toLowerCase() as Transaction['type'], + name: apiTransaction.description, + amount: apiTransaction.amount, + category: apiTransaction.category || 'Other', + date: apiTransaction.date, + note: apiTransaction.notes, +}); + +// Async thunks +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'); + } +}); + +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'); + } +}); + +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'); + } +}); + const cashflowSlice = createSlice({ name: 'cashflow', initialState, @@ -242,7 +158,50 @@ const cashflowSlice = createSlice({ removeTransaction: (state, action: PayloadAction) => { state.transactions = state.transactions.filter(t => t.id !== action.payload); } - } + }, + extraReducers: builder => { + // Fetch income sources + builder.addCase(fetchIncomeSources.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(fetchIncomeSources.fulfilled, (state, action) => { + state.isLoading = false; + state.incomeSources = action.payload; + }); + builder.addCase(fetchIncomeSources.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + + // Fetch expenses + builder.addCase(fetchExpenses.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(fetchExpenses.fulfilled, (state, action) => { + state.isLoading = false; + state.expenses = action.payload; + }); + builder.addCase(fetchExpenses.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + + // Fetch transactions + builder.addCase(fetchTransactions.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(fetchTransactions.fulfilled, (state, action) => { + state.isLoading = false; + state.transactions = action.payload; + }); + builder.addCase(fetchTransactions.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + }, }); export const { diff --git a/frontend-web/src/store/slices/debtsSlice.ts b/frontend-web/src/store/slices/debtsSlice.ts index 1daef3b..b87f303 100644 --- a/frontend-web/src/store/slices/debtsSlice.ts +++ b/frontend-web/src/store/slices/debtsSlice.ts @@ -50,104 +50,10 @@ const defaultCategories: DebtCategory[] = [ {id: 'other', name: 'Other', color: '#6b7280', createdAt: new Date().toISOString()} ]; -// Mock data for development -const mockAccounts: DebtAccount[] = [ - { - id: 'cc1', - name: 'Chase Sapphire Preferred', - categoryId: 'credit-cards', - institution: 'Chase', - accountNumber: '4521', - originalBalance: 8500, - currentBalance: 3200, - interestRate: 21.99, - minimumPayment: 95, - dueDay: 15, - createdAt: '2024-01-15', - updatedAt: '2024-12-01' - }, - { - id: 'cc2', - name: 'Amex Blue Cash', - categoryId: 'credit-cards', - institution: 'American Express', - accountNumber: '1008', - originalBalance: 4200, - currentBalance: 1850, - interestRate: 19.24, - minimumPayment: 55, - dueDay: 22, - createdAt: '2024-02-10', - updatedAt: '2024-12-01' - }, - { - id: 'cc3', - name: 'Citi Double Cash', - categoryId: 'credit-cards', - institution: 'Citibank', - accountNumber: '7732', - originalBalance: 2800, - currentBalance: 950, - interestRate: 18.49, - minimumPayment: 35, - dueDay: 8, - createdAt: '2024-03-05', - updatedAt: '2024-12-01' - }, - { - id: 'al1', - name: 'Tesla Model 3 Loan', - categoryId: 'auto-loans', - institution: 'Tesla Finance', - accountNumber: '9901', - originalBalance: 42000, - currentBalance: 15000, - interestRate: 4.99, - minimumPayment: 650, - dueDay: 1, - createdAt: '2021-06-15', - updatedAt: '2024-12-01' - }, - { - id: 'sl1', - name: 'Federal Student Loan', - categoryId: 'student-loans', - institution: 'Dept of Education', - originalBalance: 45000, - currentBalance: 28000, - interestRate: 5.5, - minimumPayment: 320, - dueDay: 25, - createdAt: '2018-09-01', - updatedAt: '2024-12-01' - }, - { - id: 'pl1', - name: 'Home Improvement Loan', - categoryId: 'personal-loans', - institution: 'SoFi', - accountNumber: '3344', - originalBalance: 15000, - currentBalance: 8500, - interestRate: 8.99, - minimumPayment: 285, - dueDay: 12, - createdAt: '2023-08-20', - updatedAt: '2024-12-01' - } -]; - -const mockPayments: DebtPayment[] = [ - {id: 'p1', accountId: 'cc1', amount: 500, date: '2024-11-15', note: 'Extra payment'}, - {id: 'p2', accountId: 'cc2', amount: 200, date: '2024-11-22'}, - {id: 'p3', accountId: 'al1', amount: 650, date: '2024-12-01'}, - {id: 'p4', accountId: 'sl1', amount: 320, date: '2024-11-25'} -]; - const initialState: DebtsState = { categories: defaultCategories, - accounts: mockAccounts, - payments: mockPayments, + accounts: [], + payments: [], isLoading: false, error: null }; diff --git a/frontend-web/src/store/slices/invoicesSlice.ts b/frontend-web/src/store/slices/invoicesSlice.ts index 305eaf1..ff67281 100644 --- a/frontend-web/src/store/slices/invoicesSlice.ts +++ b/frontend-web/src/store/slices/invoicesSlice.ts @@ -42,129 +42,9 @@ export interface InvoicesState { error: string | null; } -// Mock data for development -const mockClients: Client[] = [ - { - id: 'c1', - name: 'Acme Corp', - email: 'billing@acme.com', - phone: '555-0100', - company: 'Acme Corporation', - address: '123 Business Ave, Suite 400, San Francisco, CA 94102', - createdAt: '2024-01-10' - }, - { - id: 'c2', - name: 'TechStart Inc', - email: 'accounts@techstart.io', - phone: '555-0200', - company: 'TechStart Inc', - address: '456 Innovation Blvd, Austin, TX 78701', - createdAt: '2024-02-15' - }, - { - id: 'c3', - name: 'Sarah Mitchell', - email: 'sarah@mitchell.design', - company: 'Mitchell Design Studio', - createdAt: '2024-03-22' - }, - { - id: 'c4', - name: 'Global Media LLC', - email: 'finance@globalmedia.com', - phone: '555-0400', - company: 'Global Media LLC', - address: '789 Media Row, New York, NY 10001', - createdAt: '2024-04-08' - } -]; - -const mockInvoices: Invoice[] = [ - { - id: 'inv1', - invoiceNumber: 'INV-2024-001', - clientId: 'c1', - status: 'paid', - issueDate: '2024-10-01', - dueDate: '2024-10-31', - lineItems: [ - {id: 'li1', description: 'Web Development - October', quantity: 80, unitPrice: 150, total: 12000}, - {id: 'li2', description: 'Hosting & Maintenance', quantity: 1, unitPrice: 500, total: 500} - ], - subtotal: 12500, - tax: 0, - total: 12500, - createdAt: '2024-10-01', - updatedAt: '2024-10-15' - }, - { - id: 'inv2', - invoiceNumber: 'INV-2024-002', - clientId: 'c2', - status: 'paid', - issueDate: '2024-10-15', - dueDate: '2024-11-14', - lineItems: [{id: 'li3', description: 'Mobile App Development', quantity: 120, unitPrice: 175, total: 21000}], - subtotal: 21000, - tax: 0, - total: 21000, - createdAt: '2024-10-15', - updatedAt: '2024-11-10' - }, - { - id: 'inv3', - invoiceNumber: 'INV-2024-003', - clientId: 'c1', - status: 'sent', - issueDate: '2024-11-01', - dueDate: '2024-12-01', - lineItems: [ - {id: 'li4', description: 'Web Development - November', quantity: 60, unitPrice: 150, total: 9000}, - {id: 'li5', description: 'API Integration', quantity: 20, unitPrice: 175, total: 3500} - ], - subtotal: 12500, - tax: 0, - total: 12500, - createdAt: '2024-11-01', - updatedAt: '2024-11-01' - }, - { - id: 'inv4', - invoiceNumber: 'INV-2024-004', - clientId: 'c3', - status: 'overdue', - issueDate: '2024-10-20', - dueDate: '2024-11-20', - lineItems: [{id: 'li6', description: 'Brand Identity Design', quantity: 1, unitPrice: 4500, total: 4500}], - subtotal: 4500, - tax: 0, - total: 4500, - createdAt: '2024-10-20', - updatedAt: '2024-10-20' - }, - { - id: 'inv5', - invoiceNumber: 'INV-2024-005', - clientId: 'c4', - status: 'draft', - issueDate: '2024-12-01', - dueDate: '2024-12-31', - lineItems: [ - {id: 'li7', description: 'Video Production', quantity: 5, unitPrice: 2000, total: 10000}, - {id: 'li8', description: 'Motion Graphics', quantity: 10, unitPrice: 500, total: 5000} - ], - subtotal: 15000, - tax: 0, - total: 15000, - createdAt: '2024-12-01', - updatedAt: '2024-12-01' - } -]; - const initialState: InvoicesState = { - clients: mockClients, - invoices: mockInvoices, + clients: [], + invoices: [], isLoading: false, error: null }; diff --git a/frontend-web/src/store/slices/netWorthSlice.ts b/frontend-web/src/store/slices/netWorthSlice.ts index 25c15a9..df506c6 100644 --- a/frontend-web/src/store/slices/netWorthSlice.ts +++ b/frontend-web/src/store/slices/netWorthSlice.ts @@ -1,4 +1,15 @@ -import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; +import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit'; +import { + assetService, + liabilityService, + snapshotService, + type Asset as ApiAsset, + type Liability as ApiLiability, + type CreateAssetRequest, + type UpdateAssetRequest, + type CreateLiabilityRequest, + type UpdateLiabilityRequest, +} from '@/lib/api/networth.service'; export interface Asset { id: string; @@ -32,39 +43,118 @@ export interface NetWorthState { error: string | null; } -// Mock data for development -const mockAssets: Asset[] = [ - {id: 'a1', name: 'Chase Checking', type: 'cash', value: 12500, updatedAt: '2024-12-01'}, - {id: 'a2', name: 'Ally Savings', type: 'cash', value: 35000, updatedAt: '2024-12-01'}, - {id: 'a3', name: 'Fidelity 401k', type: 'investment', value: 145000, updatedAt: '2024-12-01'}, - {id: 'a4', name: 'Vanguard Brokerage', type: 'investment', value: 52000, updatedAt: '2024-12-01'}, - {id: 'a5', name: 'Primary Residence', type: 'property', value: 425000, updatedAt: '2024-12-01'}, - {id: 'a6', name: '2021 Tesla Model 3', type: 'vehicle', value: 28000, updatedAt: '2024-12-01'} -]; - -const mockLiabilities: Liability[] = [ - {id: 'l1', name: 'Mortgage', type: 'mortgage', balance: 320000, updatedAt: '2024-12-01'}, - {id: 'l2', name: 'Auto Loan', type: 'loan', balance: 15000, updatedAt: '2024-12-01'}, - {id: 'l3', name: 'Student Loans', type: 'loan', balance: 28000, updatedAt: '2024-12-01'} -]; - -const mockSnapshots: NetWorthSnapshot[] = [ - {id: 's1', date: '2024-07-01', totalAssets: 650000, totalLiabilities: 380000, netWorth: 270000}, - {id: 's2', date: '2024-08-01', totalAssets: 665000, totalLiabilities: 375000, netWorth: 290000}, - {id: 's3', date: '2024-09-01', totalAssets: 680000, totalLiabilities: 370000, netWorth: 310000}, - {id: 's4', date: '2024-10-01', totalAssets: 685000, totalLiabilities: 368000, netWorth: 317000}, - {id: 's5', date: '2024-11-01', totalAssets: 692000, totalLiabilities: 365000, netWorth: 327000}, - {id: 's6', date: '2024-12-01', totalAssets: 697500, totalLiabilities: 363000, netWorth: 334500} -]; - const initialState: NetWorthState = { - assets: mockAssets, - liabilities: mockLiabilities, - snapshots: mockSnapshots, + assets: [], + liabilities: [], + snapshots: [], isLoading: false, error: null }; +// Helper functions to map between API and UI types +const mapApiAssetToAsset = (apiAsset: ApiAsset): Asset => ({ + id: apiAsset.id, + name: apiAsset.name, + type: apiAsset.type.toLowerCase() as Asset['type'], + value: apiAsset.value, + updatedAt: apiAsset.updatedAt || new Date().toISOString(), +}); + +const mapApiLiabilityToLiability = (apiLiability: ApiLiability): Liability => ({ + id: apiLiability.id, + name: apiLiability.name, + type: apiLiability.type.toLowerCase() as Liability['type'], + balance: apiLiability.currentBalance, + updatedAt: apiLiability.updatedAt || new Date().toISOString(), +}); + +// Async thunks for assets +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'); + } +}); + +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'); + } +}); + +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'); + } +}); + +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'); + } +}); + +// Async thunks for liabilities +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'); + } +}); + +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'); + } +}); + +export const updateLiability = createAsyncThunk( + 'netWorth/updateLiability', + 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'); + } + } +); + +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'); + } +}); + +// Async thunks for snapshots +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'); + } +}); + const netWorthSlice = createSlice({ name: 'netWorth', initialState, @@ -75,36 +165,84 @@ const netWorthSlice = createSlice({ setError: (state, action: PayloadAction) => { state.error = action.payload; }, - addAsset: (state, action: PayloadAction) => { + }, + extraReducers: builder => { + // Fetch assets + builder.addCase(fetchAssets.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(fetchAssets.fulfilled, (state, action) => { + state.isLoading = false; + state.assets = action.payload; + }); + builder.addCase(fetchAssets.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + + // Create asset + builder.addCase(createAsset.fulfilled, (state, action) => { state.assets.push(action.payload); - }, - updateAsset: (state, action: PayloadAction) => { + }); + + // Update asset + builder.addCase(updateAsset.fulfilled, (state, action) => { const index = state.assets.findIndex(a => a.id === action.payload.id); if (index !== -1) state.assets[index] = action.payload; - }, - removeAsset: (state, action: PayloadAction) => { + }); + + // Delete asset + builder.addCase(deleteAsset.fulfilled, (state, action) => { state.assets = state.assets.filter(a => a.id !== action.payload); - }, - addLiability: (state, action: PayloadAction) => { + }); + + // Fetch liabilities + builder.addCase(fetchLiabilities.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(fetchLiabilities.fulfilled, (state, action) => { + state.isLoading = false; + state.liabilities = action.payload; + }); + builder.addCase(fetchLiabilities.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + + // Create liability + builder.addCase(createLiability.fulfilled, (state, action) => { state.liabilities.push(action.payload); - }, - updateLiability: (state, action: PayloadAction) => { + }); + + // Update liability + builder.addCase(updateLiability.fulfilled, (state, action) => { const index = state.liabilities.findIndex(l => l.id === action.payload.id); if (index !== -1) state.liabilities[index] = action.payload; - }, - removeLiability: (state, action: PayloadAction) => { + }); + + // Delete liability + builder.addCase(deleteLiability.fulfilled, (state, action) => { state.liabilities = state.liabilities.filter(l => l.id !== action.payload); - }, - addSnapshot: (state, action: PayloadAction) => { - state.snapshots.push(action.payload); - }, - setSnapshots: (state, action: PayloadAction) => { + }); + + // Fetch snapshots + builder.addCase(fetchSnapshots.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(fetchSnapshots.fulfilled, (state, action) => { + state.isLoading = false; state.snapshots = action.payload; - } - } + }); + builder.addCase(fetchSnapshots.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + }, }); -export const {setLoading, setError, addAsset, updateAsset, removeAsset, addLiability, updateLiability, removeLiability, addSnapshot, setSnapshots} = - netWorthSlice.actions; +export const {setLoading, setError} = netWorthSlice.actions; export default netWorthSlice.reducer; diff --git a/frontend-web/src/store/slices/userSlice.ts b/frontend-web/src/store/slices/userSlice.ts index d97b5f5..3cb0202 100644 --- a/frontend-web/src/store/slices/userSlice.ts +++ b/frontend-web/src/store/slices/userSlice.ts @@ -1,4 +1,5 @@ -import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; +import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit'; +import {authService, type RegisterRequest, type LoginRequest} from '@/lib/api/auth.service'; export interface User { id: string; @@ -20,6 +21,40 @@ const initialState: UserState = { error: null }; +// Async thunks +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'); + } +}); + +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'); + } +}); + +export const loadUserFromStorage = createAsyncThunk('user/loadFromStorage', async (_, {rejectWithValue}) => { + try { + const user = authService.getCurrentUser(); + if (!user) { + return rejectWithValue('No user found'); + } + // Verify token is still valid by fetching profile + await authService.getProfile(); + return user; + } catch (error: any) { + authService.logout(); + return rejectWithValue(error.message || 'Session expired'); + } +}); + const userSlice = createSlice({ name: 'user', initialState, @@ -33,6 +68,7 @@ const userSlice = createSlice({ state.error = null; }, clearUser: state => { + authService.logout(); state.currentUser = null; state.isAuthenticated = false; state.error = null; @@ -41,6 +77,54 @@ const userSlice = createSlice({ state.error = action.payload; state.isLoading = false; } + }, + extraReducers: builder => { + // Register + builder.addCase(registerUser.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(registerUser.fulfilled, (state, action) => { + state.isLoading = false; + state.currentUser = action.payload; + state.isAuthenticated = true; + state.error = null; + }); + builder.addCase(registerUser.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + + // Login + builder.addCase(loginUser.pending, state => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(loginUser.fulfilled, (state, action) => { + state.isLoading = false; + state.currentUser = action.payload; + state.isAuthenticated = true; + state.error = null; + }); + builder.addCase(loginUser.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + + // Load from storage + builder.addCase(loadUserFromStorage.pending, state => { + state.isLoading = true; + }); + builder.addCase(loadUserFromStorage.fulfilled, (state, action) => { + state.isLoading = false; + state.currentUser = action.payload; + state.isAuthenticated = true; + }); + builder.addCase(loadUserFromStorage.rejected, state => { + state.isLoading = false; + state.currentUser = null; + state.isAuthenticated = false; + }); } }); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2c56f6e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,305 @@ +{ + "name": "personal-finances", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "personal-finances", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "concurrently": "^9.2.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +}