From cd93dcbfd2292d7dc2f87a2a753a7fa402cedafe Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 7 Dec 2025 12:59:09 -0500 Subject: [PATCH] Add backend API for personal finance management application - Introduced a comprehensive backend API using TypeScript, Fastify, and PostgreSQL. - Added essential files including architecture documentation, environment configuration, and Docker setup. - Implemented RESTful routes for managing assets, liabilities, clients, invoices, and cashflow. - Established a robust database schema with Prisma for data management. - Integrated middleware for authentication and error handling. - Created service and repository layers to adhere to SOLID principles and clean architecture. - Included example environment variables for development, staging, and production setups. --- BACKEND_PROMPT.md | 810 ++++++++++++++++++ .../use-bun-instead-of-node-vite-npm-pnpm.mdc | 1 + backend-api/.env.example | 6 + backend-api/.gitignore | 34 + backend-api/ARCHITECTURE.md | 366 ++++++++ backend-api/CLAUDE.md | 111 +++ backend-api/README.md | 15 + backend-api/bun.lock | 308 +++++++ backend-api/index.ts | 1 + backend-api/package.json | 34 + backend-api/prisma/schema.prisma | 251 ++++++ backend-api/src/config/database.ts | 29 + backend-api/src/config/env.ts | 30 + .../src/controllers/AssetController.ts | 62 ++ backend-api/src/controllers/AuthController.ts | 72 ++ .../src/controllers/CashflowController.ts | 192 +++++ .../src/controllers/ClientController.ts | 103 +++ .../src/controllers/DashboardController.ts | 17 + .../src/controllers/DebtAccountController.ts | 116 +++ .../src/controllers/DebtCategoryController.ts | 89 ++ .../src/controllers/DebtPaymentController.ts | 94 ++ .../src/controllers/InvoiceController.ts | 137 +++ .../src/controllers/LiabilityController.ts | 113 +++ .../src/controllers/NetWorthController.ts | 129 +++ backend-api/src/index.ts | 37 + backend-api/src/middleware/auth.ts | 36 + backend-api/src/middleware/errorHandler.ts | 64 ++ .../src/repositories/AssetRepository.ts | 49 ++ .../src/repositories/CashflowRepository.ts | 149 ++++ .../src/repositories/ClientRepository.ts | 121 +++ .../src/repositories/DebtAccountRepository.ts | 118 +++ .../repositories/DebtCategoryRepository.ts | 117 +++ .../src/repositories/DebtPaymentRepository.ts | 130 +++ .../src/repositories/InvoiceRepository.ts | 76 ++ .../src/repositories/LiabilityRepository.ts | 73 ++ .../NetWorthSnapshotRepository.ts | 112 +++ .../src/repositories/UserRepository.ts | 51 ++ .../repositories/interfaces/IRepository.ts | 21 + backend-api/src/routes/assets.ts | 94 ++ backend-api/src/routes/auth.ts | 53 ++ backend-api/src/routes/cashflow.routes.ts | 217 +++++ backend-api/src/routes/client.routes.ts | 231 +++++ backend-api/src/routes/dashboard.routes.ts | 106 +++ backend-api/src/routes/debt.routes.ts | 559 ++++++++++++ backend-api/src/routes/invoice.routes.ts | 337 ++++++++ backend-api/src/routes/liability.routes.ts | 263 ++++++ backend-api/src/routes/networth.routes.ts | 279 ++++++ backend-api/src/server.ts | 98 +++ backend-api/src/services/AssetService.ts | 91 ++ backend-api/src/services/AuthService.ts | 68 ++ backend-api/src/services/CashflowService.ts | 162 ++++ backend-api/src/services/ClientService.ts | 148 ++++ backend-api/src/services/DashboardService.ts | 96 +++ .../src/services/DebtAccountService.ts | 168 ++++ .../src/services/DebtCategoryService.ts | 156 ++++ .../src/services/DebtPaymentService.ts | 143 ++++ backend-api/src/services/InvoiceService.ts | 164 ++++ backend-api/src/services/LiabilityService.ts | 135 +++ backend-api/src/services/NetWorthService.ts | 184 ++++ backend-api/src/utils/errors.ts | 38 + backend-api/src/utils/password.ts | 39 + backend-api/tsconfig.json | 29 + docker-compose.yml | 66 ++ env.example | 19 + frontend-web/src/App.tsx | 41 +- frontend-web/src/components/Layout.tsx | 30 +- .../src/components/ProtectedRoute.tsx | 13 + .../src/components/dialogs/LoginDialog.tsx | 88 ++ .../src/components/dialogs/SignUpDialog.tsx | 122 +++ frontend-web/src/pages/LandingPage.tsx | 144 ++++ 70 files changed, 8649 insertions(+), 6 deletions(-) create mode 100644 BACKEND_PROMPT.md create mode 120000 backend-api/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc create mode 100644 backend-api/.env.example create mode 100644 backend-api/.gitignore create mode 100644 backend-api/ARCHITECTURE.md create mode 100644 backend-api/CLAUDE.md create mode 100644 backend-api/README.md create mode 100644 backend-api/bun.lock create mode 100644 backend-api/index.ts create mode 100644 backend-api/package.json create mode 100644 backend-api/prisma/schema.prisma create mode 100644 backend-api/src/config/database.ts create mode 100644 backend-api/src/config/env.ts create mode 100644 backend-api/src/controllers/AssetController.ts create mode 100644 backend-api/src/controllers/AuthController.ts create mode 100644 backend-api/src/controllers/CashflowController.ts create mode 100644 backend-api/src/controllers/ClientController.ts create mode 100644 backend-api/src/controllers/DashboardController.ts create mode 100644 backend-api/src/controllers/DebtAccountController.ts create mode 100644 backend-api/src/controllers/DebtCategoryController.ts create mode 100644 backend-api/src/controllers/DebtPaymentController.ts create mode 100644 backend-api/src/controllers/InvoiceController.ts create mode 100644 backend-api/src/controllers/LiabilityController.ts create mode 100644 backend-api/src/controllers/NetWorthController.ts create mode 100644 backend-api/src/index.ts create mode 100644 backend-api/src/middleware/auth.ts create mode 100644 backend-api/src/middleware/errorHandler.ts create mode 100644 backend-api/src/repositories/AssetRepository.ts create mode 100644 backend-api/src/repositories/CashflowRepository.ts create mode 100644 backend-api/src/repositories/ClientRepository.ts create mode 100644 backend-api/src/repositories/DebtAccountRepository.ts create mode 100644 backend-api/src/repositories/DebtCategoryRepository.ts create mode 100644 backend-api/src/repositories/DebtPaymentRepository.ts create mode 100644 backend-api/src/repositories/InvoiceRepository.ts create mode 100644 backend-api/src/repositories/LiabilityRepository.ts create mode 100644 backend-api/src/repositories/NetWorthSnapshotRepository.ts create mode 100644 backend-api/src/repositories/UserRepository.ts create mode 100644 backend-api/src/repositories/interfaces/IRepository.ts create mode 100644 backend-api/src/routes/assets.ts create mode 100644 backend-api/src/routes/auth.ts create mode 100644 backend-api/src/routes/cashflow.routes.ts create mode 100644 backend-api/src/routes/client.routes.ts create mode 100644 backend-api/src/routes/dashboard.routes.ts create mode 100644 backend-api/src/routes/debt.routes.ts create mode 100644 backend-api/src/routes/invoice.routes.ts create mode 100644 backend-api/src/routes/liability.routes.ts create mode 100644 backend-api/src/routes/networth.routes.ts create mode 100644 backend-api/src/server.ts create mode 100644 backend-api/src/services/AssetService.ts create mode 100644 backend-api/src/services/AuthService.ts create mode 100644 backend-api/src/services/CashflowService.ts create mode 100644 backend-api/src/services/ClientService.ts create mode 100644 backend-api/src/services/DashboardService.ts create mode 100644 backend-api/src/services/DebtAccountService.ts create mode 100644 backend-api/src/services/DebtCategoryService.ts create mode 100644 backend-api/src/services/DebtPaymentService.ts create mode 100644 backend-api/src/services/InvoiceService.ts create mode 100644 backend-api/src/services/LiabilityService.ts create mode 100644 backend-api/src/services/NetWorthService.ts create mode 100644 backend-api/src/utils/errors.ts create mode 100644 backend-api/src/utils/password.ts create mode 100644 backend-api/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 env.example create mode 100644 frontend-web/src/components/ProtectedRoute.tsx create mode 100644 frontend-web/src/components/dialogs/LoginDialog.tsx create mode 100644 frontend-web/src/components/dialogs/SignUpDialog.tsx create mode 100644 frontend-web/src/pages/LandingPage.tsx diff --git a/BACKEND_PROMPT.md b/BACKEND_PROMPT.md new file mode 100644 index 0000000..feb244e --- /dev/null +++ b/BACKEND_PROMPT.md @@ -0,0 +1,810 @@ +# Backend API Development Prompt + +Build a REST API backend for a personal finance management application using **TypeScript**, **Fastify**, and **PostgreSQL**. + +## Tech Stack + +- **Runtime:** Node.js with TypeScript +- **Framework:** Fastify +- **Database:** PostgreSQL +- **ORM:** Drizzle ORM (recommended) or Prisma +- **Authentication:** JWT with refresh tokens +- **Validation:** Zod or TypeBox +- **Password hashing:** bcrypt or argon2 + +## Project Structure + +``` +backend-api/ +├── src/ +│ ├── index.ts # Entry point +│ ├── app.ts # Fastify app setup +│ ├── config/ # Environment config +│ ├── db/ +│ │ ├── schema.ts # Database schema +│ │ ├── migrations/ # Database migrations +│ │ └── client.ts # DB connection +│ ├── modules/ +│ │ ├── auth/ # Auth routes, handlers, service +│ │ ├── users/ +│ │ ├── net-worth/ +│ │ ├── debts/ +│ │ ├── invoices/ +│ │ └── cashflow/ +│ ├── middleware/ +│ │ └── auth.ts # JWT verification +│ └── utils/ +├── package.json +├── tsconfig.json +├── drizzle.config.ts +└── .env.example +``` + +--- + +## Database Schema + +### Users + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Assets (Net Worth) + +```sql +CREATE TABLE assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL CHECK (type IN ('cash', 'investment', 'property', 'vehicle', 'other')), + value DECIMAL(15, 2) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Liabilities (Net Worth) + +```sql +CREATE TABLE liabilities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL CHECK (type IN ('credit_card', 'loan', 'mortgage', 'other')), + balance DECIMAL(15, 2) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Net Worth Snapshots + +```sql +CREATE TABLE net_worth_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + date DATE NOT NULL, + total_assets DECIMAL(15, 2) NOT NULL, + total_liabilities DECIMAL(15, 2) NOT NULL, + net_worth DECIMAL(15, 2) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### Debt Categories + +```sql +CREATE TABLE debt_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + color VARCHAR(20) DEFAULT '#6b7280', + is_default BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### Debt Accounts + +```sql +CREATE TABLE debt_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + category_id UUID REFERENCES debt_categories(id) ON DELETE SET NULL, + name VARCHAR(255) NOT NULL, + institution VARCHAR(255), + account_number VARCHAR(4), -- Last 4 digits only + original_balance DECIMAL(15, 2) NOT NULL, + current_balance DECIMAL(15, 2) NOT NULL, + interest_rate DECIMAL(5, 2), + minimum_payment DECIMAL(10, 2), + due_day INTEGER CHECK (due_day >= 1 AND due_day <= 31), + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Debt Payments + +```sql +CREATE TABLE debt_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID REFERENCES debt_accounts(id) ON DELETE CASCADE, + amount DECIMAL(10, 2) NOT NULL, + date DATE NOT NULL, + note TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### Clients (Invoicing) + +```sql +CREATE TABLE clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + phone VARCHAR(50), + company VARCHAR(255), + address TEXT, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### Invoices + +```sql +CREATE TABLE invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + client_id UUID REFERENCES clients(id) ON DELETE SET NULL, + invoice_number VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL CHECK (status IN ('draft', 'sent', 'paid', 'overdue', 'cancelled')), + issue_date DATE NOT NULL, + due_date DATE NOT NULL, + subtotal DECIMAL(15, 2) NOT NULL, + tax DECIMAL(15, 2) DEFAULT 0, + total DECIMAL(15, 2) NOT NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Invoice Line Items + +```sql +CREATE TABLE invoice_line_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id UUID REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + quantity DECIMAL(10, 2) NOT NULL, + unit_price DECIMAL(15, 2) NOT NULL, + total DECIMAL(15, 2) NOT NULL +); +``` + +### Income Sources (Cashflow) + +```sql +CREATE TABLE income_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + frequency VARCHAR(20) NOT NULL CHECK (frequency IN ('weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'once')), + category VARCHAR(100), + next_date DATE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### Expenses (Cashflow) + +```sql +CREATE TABLE expenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + frequency VARCHAR(20) NOT NULL CHECK (frequency IN ('weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'once')), + category VARCHAR(100), + next_date DATE, + is_active BOOLEAN DEFAULT TRUE, + is_essential BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### Transactions (Cashflow) + +```sql +CREATE TABLE transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(10) NOT NULL CHECK (type IN ('income', 'expense')), + name VARCHAR(255) NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + category VARCHAR(100), + date DATE NOT NULL, + note TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +--- + +## API Endpoints + +All API routes are prefixed with `/api` to avoid conflicts with frontend routes. + +### Authentication + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/auth/register` | Register new user | +| POST | `/api/auth/login` | Login, returns JWT + refresh token | +| POST | `/api/auth/refresh` | Refresh access token | +| POST | `/api/auth/logout` | Invalidate refresh token | +| GET | `/api/auth/me` | Get current user profile | + +### Assets + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/assets` | List all assets for user | +| POST | `/api/assets` | Create new asset | +| GET | `/api/assets/:id` | Get asset by ID | +| PUT | `/api/assets/:id` | Update asset | +| DELETE | `/api/assets/:id` | Delete asset | + +### Liabilities + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/liabilities` | List all liabilities for user | +| POST | `/api/liabilities` | Create new liability | +| GET | `/api/liabilities/:id` | Get liability by ID | +| PUT | `/api/liabilities/:id` | Update liability | +| DELETE | `/api/liabilities/:id` | Delete liability | + +### Net Worth Snapshots + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/net-worth/snapshots` | List snapshots (with date range filter) | +| POST | `/api/net-worth/snapshots` | Create snapshot (auto-calculates totals) | +| GET | `/api/net-worth/current` | Get current net worth calculation | + +### Debt Categories + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/debts/categories` | List all categories | +| POST | `/api/debts/categories` | Create category | +| PUT | `/api/debts/categories/:id` | Update category | +| DELETE | `/api/debts/categories/:id` | Delete category (moves accounts to "Other") | + +### Debt Accounts + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/debts/accounts` | List all debt accounts | +| POST | `/api/debts/accounts` | Create debt account | +| GET | `/api/debts/accounts/:id` | Get account with payment history | +| PUT | `/api/debts/accounts/:id` | Update account | +| DELETE | `/api/debts/accounts/:id` | Delete account | + +### Debt Payments + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/debts/accounts/:id/payments` | List payments for account | +| POST | `/api/debts/accounts/:id/payments` | Record payment (updates balance) | +| DELETE | `/api/debts/payments/:id` | Delete payment (restores balance) | + +### Clients + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/clients` | List all clients | +| POST | `/api/clients` | Create client | +| GET | `/api/clients/:id` | Get client with invoice stats | +| PUT | `/api/clients/:id` | Update client | +| DELETE | `/api/clients/:id` | Delete client | + +### Invoices + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/invoices` | List invoices (filterable by status, client) | +| POST | `/api/invoices` | Create invoice with line items | +| GET | `/api/invoices/:id` | Get invoice with line items | +| PUT | `/api/invoices/:id` | Update invoice | +| PATCH | `/api/invoices/:id/status` | Update invoice status only | +| DELETE | `/api/invoices/:id` | Delete invoice | + +### Income Sources + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/cashflow/income` | List income sources | +| POST | `/api/cashflow/income` | Create income source | +| PUT | `/api/cashflow/income/:id` | Update income source | +| DELETE | `/api/cashflow/income/:id` | Delete income source | + +### Expenses + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/cashflow/expenses` | List expenses | +| POST | `/api/cashflow/expenses` | Create expense | +| PUT | `/api/cashflow/expenses/:id` | Update expense | +| DELETE | `/api/cashflow/expenses/:id` | Delete expense | + +### Transactions + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/cashflow/transactions` | List transactions (with date range, pagination) | +| POST | `/api/cashflow/transactions` | Create transaction | +| DELETE | `/api/cashflow/transactions/:id` | Delete transaction | + +### Dashboard / Summary + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/dashboard/summary` | Get aggregated summary stats | + +### Health Check + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/health` | Health check (no auth required) | + +--- + +## Authentication Implementation + +1. **Registration:** + - Validate email uniqueness + - Hash password with bcrypt (cost factor 12) + - Create default debt categories for new users + - Return JWT access token (15min expiry) + refresh token (7 days) + +2. **Login:** + - Validate credentials + - Return JWT + refresh token + - Store refresh token hash in DB or Redis + +3. **JWT Payload:** + ```typescript + interface JWTPayload { + sub: string; // user ID + email: string; + iat: number; + exp: number; + } + ``` + +4. **Protected Routes:** + - Add `preHandler` hook to verify JWT + - Extract user ID from token for all queries + +--- + +## Request/Response Types + +Use consistent response format: + +```typescript +// Success +{ + success: true, + data: T +} + +// Error +{ + success: false, + error: { + code: string, + message: string, + details?: Record + } +} + +// Paginated +{ + success: true, + data: T[], + meta: { + page: number, + limit: number, + total: number, + totalPages: number + } +} +``` + +--- + +## Validation Rules + +- **Email:** Valid email format, max 255 chars +- **Password:** Min 6 chars +- **Monetary values:** Max 2 decimal places, positive numbers +- **Dates:** ISO 8601 format (YYYY-MM-DD) +- **Interest rates:** 0-100 range, max 2 decimal places +- **Due day:** 1-31 range + +--- + +## Business Logic + +1. **Debt Payments:** + - Recording a payment should automatically update `current_balance` + - Deleting a payment should restore the balance + +2. **Net Worth Snapshots:** + - Auto-calculate totals from current assets/liabilities + - Allow manual override if needed + +3. **Invoice Numbers:** + - Auto-generate if not provided: `INV-{YEAR}-{SEQ}` + - Sequence per user + +4. **Overdue Invoices:** + - Consider adding a scheduled job to mark invoices as overdue + +--- + +## Environment Variables + +```env +DATABASE_URL=postgresql://user:pass@localhost:5432/wealth +JWT_SECRET=your-secret-key +JWT_REFRESH_SECRET=your-refresh-secret +PORT=3000 +NODE_ENV=development +``` + +--- + +## Additional Requirements + +1. **CORS:** Configure for frontend origin +2. **Rate Limiting:** Add to auth endpoints +3. **Logging:** Use Fastify's built-in pino logger +4. **Health Check:** `GET /health` endpoint +5. **API Docs:** Consider adding Swagger/OpenAPI via `@fastify/swagger` + +--- + +## Getting Started + +1. Initialize project with `bun init` or `npm init` +2. Install dependencies: + ```bash + bun add fastify @fastify/cors @fastify/jwt @fastify/static drizzle-orm postgres zod bcrypt + bun add -D typescript @types/node @types/bcrypt drizzle-kit tsx + ``` +3. Set up database schema and run migrations +4. Implement auth module first +5. Add remaining modules +6. Test all endpoints + +--- + +## Serving the Frontend + +The backend should serve the frontend static files in production. Use `@fastify/static`: + +```typescript +// src/app.ts +import fastifyStatic from '@fastify/static'; +import path from 'path'; + +// Serve frontend static files in production +if (process.env.NODE_ENV === 'production') { + app.register(fastifyStatic, { + root: path.join(__dirname, '../public'), + prefix: '/', + }); + + // SPA fallback - serve index.html for all non-API routes + app.setNotFoundHandler((request, reply) => { + if (request.url.startsWith('/api')) { + reply.status(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Route not found' } }); + } else { + reply.sendFile('index.html'); + } + }); +} + +// Register all API routes with /api prefix +app.register(authRoutes, { prefix: '/api/auth' }); +app.register(assetsRoutes, { prefix: '/api/assets' }); +app.register(liabilitiesRoutes, { prefix: '/api/liabilities' }); +// ... etc +``` + +**Important:** All API endpoints are prefixed with `/api` to avoid conflicts with frontend SPA routes. The frontend React Router handles `/`, `/cashflow`, `/debts`, etc., while the API handles `/api/*`. + +--- + +## Docker Build + +Create a multi-stage Dockerfile that builds both frontend and backend: + +```dockerfile +# Dockerfile +FROM oven/bun:1 AS frontend-builder + +WORKDIR /app/frontend +COPY frontend-web/package.json frontend-web/bun.lock ./ +RUN bun install --frozen-lockfile + +COPY frontend-web/ ./ +RUN bun run build + +# --- + +FROM oven/bun:1 AS backend-builder + +WORKDIR /app/backend +COPY backend-api/package.json backend-api/bun.lock ./ +RUN bun install --frozen-lockfile + +COPY backend-api/ ./ +RUN bun run build + +# --- + +FROM oven/bun:1-slim AS production + +WORKDIR /app + +# Copy backend build +COPY --from=backend-builder /app/backend/dist ./dist +COPY --from=backend-builder /app/backend/package.json ./ +COPY --from=backend-builder /app/backend/node_modules ./node_modules + +# Copy frontend build into public folder +COPY --from=frontend-builder /app/frontend/dist ./public + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +CMD ["bun", "run", "dist/index.js"] +``` + +--- + +## Project Structure (Updated) + +``` +personal-finances/ +├── frontend-web/ # React frontend (existing) +├── backend-api/ # Fastify backend (to create) +│ ├── src/ +│ │ ├── index.ts +│ │ ├── app.ts +│ │ ├── config/ +│ │ ├── db/ +│ │ ├── modules/ +│ │ │ ├── auth/ +│ │ │ ├── assets/ +│ │ │ ├── liabilities/ +│ │ │ ├── net-worth/ +│ │ │ ├── debts/ +│ │ │ ├── invoices/ +│ │ │ ├── clients/ +│ │ │ └── cashflow/ +│ │ ├── middleware/ +│ │ └── utils/ +│ ├── package.json +│ ├── tsconfig.json +│ └── drizzle.config.ts +├── Dockerfile +├── docker-compose.yml +└── BACKEND_PROMPT.md +``` + +--- + +## Docker Compose (Development) + +```yaml +# docker-compose.yml +version: '3.8' + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: wealth + POSTGRES_PASSWORD: wealth_dev + POSTGRES_DB: wealth + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + api: + build: . + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://wealth:wealth_dev@db:5432/wealth + JWT_SECRET: dev-secret-change-in-production + JWT_REFRESH_SECRET: dev-refresh-secret-change-in-production + NODE_ENV: production + depends_on: + - db + +volumes: + postgres_data: +``` + +--- + +## Build Scripts + +Add these scripts to `backend-api/package.json`: + +```json +{ + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" + } +} +``` + +Root-level scripts for building everything (add to root `package.json`): + +```json +{ + "scripts": { + "build:frontend": "cd frontend-web && bun run build", + "build:backend": "cd backend-api && bun run build", + "build": "bun run build:frontend && bun run build:backend", + "docker:build": "docker build -t wealth-app .", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down" + } +} +``` + +--- + +## Frontend Integration + +The frontend expects these TypeScript interfaces (match these in your responses): + +```typescript +interface User { + id: string; + email: string; + name: string; +} + +interface Asset { + id: string; + name: string; + type: 'cash' | 'investment' | 'property' | 'vehicle' | 'other'; + value: number; + updatedAt: string; +} + +interface Liability { + id: string; + name: string; + type: 'credit_card' | 'loan' | 'mortgage' | 'other'; + balance: number; + updatedAt: string; +} + +interface DebtCategory { + id: string; + name: string; + color: string; + createdAt: string; +} + +interface DebtAccount { + id: string; + name: string; + categoryId: string; + institution: string; + accountNumber?: string; + originalBalance: number; + currentBalance: number; + interestRate: number; + minimumPayment: number; + dueDay: number; + notes?: string; + createdAt: string; + updatedAt: string; +} + +interface Client { + id: string; + name: string; + email: string; + phone?: string; + company?: string; + address?: string; + notes?: string; + createdAt: string; +} + +interface Invoice { + id: string; + invoiceNumber: string; + clientId: string; + status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled'; + issueDate: string; + dueDate: string; + lineItems: InvoiceLineItem[]; + subtotal: number; + tax: number; + total: number; + notes?: string; + createdAt: string; + updatedAt: string; +} + +interface IncomeSource { + id: string; + name: string; + amount: number; + frequency: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | 'once'; + category: string; + nextDate: string; + isActive: boolean; + createdAt: string; +} + +interface Expense { + id: string; + name: string; + amount: number; + frequency: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | 'once'; + category: string; + nextDate: string; + isActive: boolean; + isEssential: boolean; + createdAt: string; +} + +interface Transaction { + id: string; + type: 'income' | 'expense'; + name: string; + amount: number; + category: string; + date: string; + note?: string; +} +``` + diff --git a/backend-api/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/backend-api/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 120000 index 0000000..6100270 --- /dev/null +++ b/backend-api/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1 @@ +../../CLAUDE.md \ No newline at end of file diff --git a/backend-api/.env.example b/backend-api/.env.example new file mode 100644 index 0000000..48051f2 --- /dev/null +++ b/backend-api/.env.example @@ -0,0 +1,6 @@ +NODE_ENV=development +PORT=3000 +DATABASE_URL="postgresql://user:password@localhost:5432/personal_finances?schema=public" +JWT_SECRET=your-secret-key-change-this-in-production +JWT_EXPIRES_IN=7d +CORS_ORIGIN=http://localhost:5174 diff --git a/backend-api/.gitignore b/backend-api/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/backend-api/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/backend-api/ARCHITECTURE.md b/backend-api/ARCHITECTURE.md new file mode 100644 index 0000000..6a42cac --- /dev/null +++ b/backend-api/ARCHITECTURE.md @@ -0,0 +1,366 @@ +# Personal Finances API - Architecture Documentation + +## Overview + +This backend API is built following **SOLID principles** and **clean architecture** patterns using TypeScript, Fastify, and PostgreSQL. + +## SOLID Principles Implementation + +### 1. Single Responsibility Principle (SRP) +Each class has one well-defined responsibility: + +- **Controllers** - Handle HTTP requests/responses only +- **Services** - Contain business logic only +- **Repositories** - Handle data access only +- **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 + // ... +} + +// AssetRepository - ONLY handles database operations +export class AssetRepository { + 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 +- **Repository Interfaces** - Implement `IRepository` for new entities +- **Service Pattern** - Add new services without modifying existing ones + +Example: +```typescript +// Extensible error hierarchy +export abstract class AppError extends Error { + abstract statusCode: number; +} + +export class NotFoundError extends AppError { + statusCode = 404; +} + +export class ValidationError extends AppError { + statusCode = 400; +} +``` + +### 3. Liskov Substitution Principle (LSP) +Derived classes can substitute their base classes: + +```typescript +// Base interface +export interface IRepository { + findById(id: string): Promise; + create(data: Partial): Promise; +} + +// Specialized interface extends base without breaking it +export interface IUserScopedRepository extends Omit, 'findAll'> { + findAllByUser(userId: string): Promise; +} +``` + +### 4. Interface Segregation Principle (ISP) +Clients depend only on interfaces they use: + +- `IRepository` - Base CRUD operations +- `IUserScopedRepository` - User-scoped operations +- Specific methods in services (e.g., `getTotalValue()` in AssetService) + +### 5. Dependency Inversion Principle (DIP) +High-level modules depend on abstractions: + +```typescript +// Service depends on repository abstraction, not concrete implementation +export class AuthService { + constructor(private userRepository: UserRepository) {} + // UserRepository implements IRepository +} + +// Singleton database connection +class DatabaseConnection { + private static instance: PrismaClient; + public static getInstance(): PrismaClient { + // ... + } +} +``` + +## Architecture Layers + +### 1. Presentation Layer (Controllers & Routes) +- **Location**: `src/controllers/`, `src/routes/` +- **Purpose**: Handle HTTP requests/responses +- **Responsibilities**: + - Parse request parameters + - Validate input schemas (using Zod) + - Call service layer + - Format responses + +```typescript +export class AssetController { + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createAssetSchema.parse(request.body); // Validation + const asset = await this.assetService.create(userId, data); // Business logic + return reply.status(201).send({asset}); // Response + } +} +``` + +### 2. Business Logic Layer (Services) +- **Location**: `src/services/` +- **Purpose**: Implement business rules +- **Responsibilities**: + - Validate business rules + - Coordinate between repositories + - Perform calculations + - Enforce authorization + +```typescript +export class InvoiceService { + async create(userId: string, data: CreateInvoiceDTO): Promise { + this.validateInvoiceData(data); // Business rule + const invoiceNumber = await this.generateInvoiceNumber(userId); // Logic + const subtotal = this.calculateSubtotal(data.lineItems); // Calculation + return this.invoiceRepository.create({...}); // Data access + } +} +``` + +### 3. Data Access Layer (Repositories) +- **Location**: `src/repositories/` +- **Purpose**: Abstract database operations +- **Responsibilities**: + - CRUD operations + - Query composition + - Data mapping + +```typescript +export class AssetRepository implements IUserScopedRepository { + async findAllByUser(userId: string): Promise { + return prisma.asset.findMany({ + where: {userId}, + orderBy: {createdAt: 'desc'}, + }); + } +} +``` + +### 4. Cross-Cutting Concerns (Middleware & Utils) +- **Location**: `src/middleware/`, `src/utils/` +- **Purpose**: Handle common functionality +- **Components**: + - Authentication middleware + - Error handling + - Password utilities + - Custom errors + +## Data Flow + +``` +HTTP Request + ↓ +Route (Fastify) + ↓ +Controller + ├→ Validate Input (Zod) + ├→ Extract User ID (Middleware) + └→ Call Service + ↓ +Service + ├→ Validate Business Rules + ├→ Perform Calculations + └→ Call Repository + ↓ +Repository + ├→ Compose Query + └→ Execute via Prisma + ↓ +Database (PostgreSQL) +``` + +## Database Schema + +### Entity Relationships + +``` +User +├── Assets (1:N) +├── Liabilities (1:N) +├── NetWorthSnapshots (1:N) +├── Clients (1:N) +│ └── Invoices (1:N) +│ └── InvoiceLineItems (1:N) +├── IncomeSources (1:N) +├── Expenses (1:N) +├── Transactions (1:N) +└── DebtCategories (1:N) + └── DebtAccounts (1:N) + └── DebtPayments (1:N) +``` + +### Key Design Decisions + +1. **Cascade Deletes**: User deletion cascades to all related data +2. **UUID Primary Keys**: For security and distributed systems +3. **Timestamps**: All entities track creation/update times +4. **Enums**: Type-safe status and category fields +5. **Composite Indexes**: Optimized queries on user_id + other fields + +## 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 + +## API Design + +### RESTful Conventions +- `GET /api/resources` - List all +- `GET /api/resources/:id` - Get one +- `POST /api/resources` - Create +- `PUT /api/resources/:id` - Update (full) +- `PATCH /api/resources/:id` - Update (partial) +- `DELETE /api/resources/:id` - Delete + +### Response Format +```json +{ + "resource": { /* data */ }, + // or + "resources": [ /* array */ ], + // or on error + "error": "ErrorType", + "message": "Human-readable message" +} +``` + +### HTTP Status Codes +- `200 OK` - Successful GET/PUT/PATCH +- `201 Created` - Successful POST +- `204 No Content` - Successful DELETE +- `400 Bad Request` - Validation error +- `401 Unauthorized` - Authentication required +- `403 Forbidden` - Insufficient permissions +- `404 Not Found` - Resource not found +- `409 Conflict` - Duplicate resource +- `500 Internal Server Error` - Server error + +## 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 + +## 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 + +## Development Workflow + +1. **Add New Feature**: + - Define Prisma schema + - Run migration + - Create repository interface + - Implement repository + - Create service with business logic + - Add controller + - Define routes + - Add tests + +2. **Modify Existing Feature**: + - Update service layer (business logic) + - Update repository if needed + - Update schema if needed + - Update tests + +## 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) +✅ Implement rate limiting +✅ Keep dependencies updated + +### Documentation +✅ JSDoc comments for public APIs +✅ README for setup instructions +✅ API documentation (Swagger) +✅ Architecture documentation (this file) + +## Future Enhancements + +- [ ] Rate limiting middleware +- [ ] Request logging +- [ ] Metrics and monitoring +- [ ] Database query optimization +- [ ] Caching layer +- [ ] WebSocket support for real-time updates +- [ ] Background job processing +- [ ] Email notifications +- [ ] Data export functionality +- [ ] Multi-tenancy support diff --git a/backend-api/CLAUDE.md b/backend-api/CLAUDE.md new file mode 100644 index 0000000..ebda995 --- /dev/null +++ b/backend-api/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/backend-api/README.md b/backend-api/README.md new file mode 100644 index 0000000..80ff561 --- /dev/null +++ b/backend-api/README.md @@ -0,0 +1,15 @@ +# backend-api + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/backend-api/bun.lock b/backend-api/bun.lock new file mode 100644 index 0000000..476d88c --- /dev/null +++ b/backend-api/bun.lock @@ -0,0 +1,308 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "backend-api", + "dependencies": { + "@fastify/cors": "^11.1.0", + "@fastify/jwt": "^10.0.0", + "@fastify/swagger": "^9.6.1", + "@fastify/swagger-ui": "^5.2.3", + "@prisma/client": "5.22.0", + "bcryptjs": "^3.0.3", + "fastify": "^5.6.2", + "zod": "^4.1.13", + }, + "devDependencies": { + "@types/bcryptjs": "^3.0.0", + "@types/bun": "latest", + "prisma": "5.22.0", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="], + + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + + "@fastify/cors": ["@fastify/cors@11.1.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA=="], + + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + + "@fastify/jwt": ["@fastify/jwt@10.0.0", "", { "dependencies": { "@fastify/error": "^4.2.0", "@lukeed/ms": "^2.0.2", "fast-jwt": "^6.0.2", "fastify-plugin": "^5.0.1", "steed": "^1.1.3" } }, "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], + + "@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="], + + "@fastify/static": ["@fastify/static@8.3.0", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^0.5.4", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^11.0.0" } }, "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA=="], + + "@fastify/swagger": ["@fastify/swagger@9.6.1", "", { "dependencies": { "fastify-plugin": "^5.0.0", "json-schema-resolver": "^3.0.0", "openapi-types": "^12.1.3", "rfdc": "^1.3.1", "yaml": "^2.4.2" } }, "sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA=="], + + "@fastify/swagger-ui": ["@fastify/swagger-ui@5.2.3", "", { "dependencies": { "@fastify/static": "^8.0.0", "fastify-plugin": "^5.0.0", "openapi-types": "^12.1.3", "rfdc": "^1.3.1", "yaml": "^2.4.1" } }, "sha512-e7ivEJi9EpFcxTONqICx4llbpB2jmlI+LI1NQ/mR7QGQnyDOqZybPK572zJtcdHZW4YyYTBHcP3a03f1pOh0SA=="], + + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], + + "@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="], + + "@prisma/engines": ["@prisma/engines@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/fetch-engine": "5.22.0", "@prisma/get-platform": "5.22.0" } }, "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA=="], + + "@prisma/engines-version": ["@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "", {}, "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/get-platform": "5.22.0" } }, "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA=="], + + "@prisma/get-platform": ["@prisma/get-platform@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0" } }, "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q=="], + + "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], + + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="], + + "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + + "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "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=="], + + "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "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=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stringify": ["fast-json-stringify@6.1.1", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ=="], + + "fast-jwt": ["fast-jwt@6.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "asn1.js": "^5.4.1", "ecdsa-sig-formatter": "^1.0.11", "mnemonist": "^0.40.0" } }, "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ=="], + + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fastfall": ["fastfall@1.5.1", "", { "dependencies": { "reusify": "^1.0.0" } }, "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q=="], + + "fastify": ["fastify@5.6.2", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.0", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg=="], + + "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + + "fastparallel": ["fastparallel@2.4.1", "", { "dependencies": { "reusify": "^1.0.4", "xtend": "^4.0.2" } }, "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fastseries": ["fastseries@1.7.2", "", { "dependencies": { "reusify": "^1.0.0", "xtend": "^4.0.0" } }, "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ=="], + + "find-my-way": ["find-my-way@9.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], + + "json-schema-resolver": ["json-schema-resolver@3.0.0", "", { "dependencies": { "debug": "^4.1.1", "fast-uri": "^3.0.5", "rfdc": "^1.1.4" } }, "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + + "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "mnemonist": ["mnemonist@0.40.3", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + + "pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], + + "prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "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=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "steed": ["steed@1.1.3", "", { "dependencies": { "fastfall": "^1.5.0", "fastparallel": "^2.2.0", "fastq": "^1.3.0", "fastseries": "^1.7.0", "reusify": "^1.0.0" } }, "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["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@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + + "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["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=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/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=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/backend-api/index.ts b/backend-api/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/backend-api/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/backend-api/package.json b/backend-api/package.json new file mode 100644 index 0000000..52e75fe --- /dev/null +++ b/backend-api/package.json @@ -0,0 +1,34 @@ +{ + "name": "personal-finances-api", + "version": "1.0.0", + "description": "Backend API for Personal Finances application", + "module": "src/index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio" + }, + "devDependencies": { + "@types/bcryptjs": "^3.0.0", + "@types/bun": "latest", + "prisma": "5.22.0" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@fastify/cors": "^11.1.0", + "@fastify/jwt": "^10.0.0", + "@fastify/swagger": "^9.6.1", + "@fastify/swagger-ui": "^5.2.3", + "@prisma/client": "5.22.0", + "bcryptjs": "^3.0.3", + "fastify": "^5.6.2", + "zod": "^4.1.13" + } +} diff --git a/backend-api/prisma/schema.prisma b/backend-api/prisma/schema.prisma new file mode 100644 index 0000000..993b1d2 --- /dev/null +++ b/backend-api/prisma/schema.prisma @@ -0,0 +1,251 @@ +// Database schema for Personal Finances API + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + email String @unique + password String + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + assets Asset[] + liabilities Liability[] + snapshots NetWorthSnapshot[] + clients Client[] + invoices Invoice[] + incomeSources IncomeSource[] + expenses Expense[] + transactions Transaction[] + debtCategories DebtCategory[] + + @@map("users") +} + +model Asset { + id String @id @default(uuid()) + userId String + name String + type AssetType + value Float + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("assets") +} + +enum AssetType { + CASH + INVESTMENT + PROPERTY + VEHICLE + OTHER +} + +model Liability { + id String @id @default(uuid()) + userId String + name String + type LiabilityType + balance Float + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("liabilities") +} + +enum LiabilityType { + CREDIT_CARD + LOAN + MORTGAGE + OTHER +} + +model NetWorthSnapshot { + id String @id @default(uuid()) + userId String + date DateTime + totalAssets Float + totalLiabilities Float + netWorth Float + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, date]) + @@map("net_worth_snapshots") +} + +model Client { + id String @id @default(uuid()) + userId String + name String + email String + phone String? + company String? + address String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + invoices Invoice[] + + @@index([userId]) + @@map("clients") +} + +model Invoice { + id String @id @default(uuid()) + userId String + clientId String + invoiceNumber String + status InvoiceStatus @default(DRAFT) + issueDate DateTime + dueDate DateTime + subtotal Float + tax Float @default(0) + total Float + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + client Client @relation(fields: [clientId], references: [id], onDelete: Restrict) + lineItems InvoiceLineItem[] + + @@unique([userId, invoiceNumber]) + @@index([userId, status]) + @@index([clientId]) + @@map("invoices") +} + +enum InvoiceStatus { + DRAFT + SENT + PAID + OVERDUE + CANCELLED +} + +model InvoiceLineItem { + id String @id @default(uuid()) + invoiceId String + description String + quantity Float + unitPrice Float + total Float + + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + @@index([invoiceId]) + @@map("invoice_line_items") +} + +model IncomeSource { + id String @id @default(uuid()) + userId String + name String + amount Float + frequency String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("income_sources") +} + +model Expense { + id String @id @default(uuid()) + userId String + name String + amount Float + category ExpenseCategory + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("expenses") +} + +enum ExpenseCategory { + ESSENTIAL + DISCRETIONARY +} + +model Transaction { + id String @id @default(uuid()) + userId String + description String + amount Float + type String + date DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, date]) + @@map("transactions") +} + +model DebtCategory { + id String @id @default(uuid()) + userId String + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accounts DebtAccount[] + + @@index([userId]) + @@map("debt_categories") +} + +model DebtAccount { + id String @id @default(uuid()) + categoryId String + name String + balance Float + interestRate Float? + minimumPayment Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + category DebtCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + payments DebtPayment[] + + @@index([categoryId]) + @@map("debt_accounts") +} + +model DebtPayment { + id String @id @default(uuid()) + accountId String + amount Float + date DateTime + createdAt DateTime @default(now()) + + account DebtAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@index([accountId, date]) + @@map("debt_payments") +} diff --git a/backend-api/src/config/database.ts b/backend-api/src/config/database.ts new file mode 100644 index 0000000..7521d49 --- /dev/null +++ b/backend-api/src/config/database.ts @@ -0,0 +1,29 @@ +import {PrismaClient} from '@prisma/client'; + +/** + * Database connection singleton + * Implements Single Responsibility: Only manages database connection + */ +class DatabaseConnection { + private static instance: PrismaClient; + + private constructor() {} + + public static getInstance(): PrismaClient { + if (!DatabaseConnection.instance) { + DatabaseConnection.instance = new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }); + } + return DatabaseConnection.instance; + } + + public static async disconnect(): Promise { + if (DatabaseConnection.instance) { + await DatabaseConnection.instance.$disconnect(); + } + } +} + +export const prisma = DatabaseConnection.getInstance(); +export {DatabaseConnection}; diff --git a/backend-api/src/config/env.ts b/backend-api/src/config/env.ts new file mode 100644 index 0000000..555529c --- /dev/null +++ b/backend-api/src/config/env.ts @@ -0,0 +1,30 @@ +import {z} from 'zod'; + +/** + * Environment configuration schema + * Validates environment variables at startup + */ +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.string().transform(Number).default('3000'), + DATABASE_URL: z.string().min(1), + JWT_SECRET: z.string().min(32), + JWT_EXPIRES_IN: z.string().default('7d'), + CORS_ORIGIN: z.string().default('http://localhost:5174'), +}); + +type EnvConfig = z.infer; + +function validateEnv(): EnvConfig { + const result = envSchema.safeParse(process.env); + + if (!result.success) { + console.error('❌ Invalid environment variables:'); + console.error(result.error.format()); + process.exit(1); + } + + return result.data; +} + +export const env = validateEnv(); diff --git a/backend-api/src/controllers/AssetController.ts b/backend-api/src/controllers/AssetController.ts new file mode 100644 index 0000000..b985075 --- /dev/null +++ b/backend-api/src/controllers/AssetController.ts @@ -0,0 +1,62 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {z} from 'zod'; +import {AssetService} from '../services/AssetService'; +import {AssetRepository} from '../repositories/AssetRepository'; +import {getUserId} from '../middleware/auth'; +import {AssetType} from '@prisma/client'; + +const createAssetSchema = z.object({ + name: z.string().min(1), + type: z.nativeEnum(AssetType), + value: z.number().min(0), +}); + +const updateAssetSchema = z.object({ + name: z.string().min(1).optional(), + type: z.nativeEnum(AssetType).optional(), + value: z.number().min(0).optional(), +}); + +/** + * Asset Controller + * Handles asset-related HTTP requests + */ +export class AssetController { + private assetService: AssetService; + + constructor() { + this.assetService = new AssetService(new AssetRepository()); + } + + async getAll(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const assets = await this.assetService.getAll(userId); + return reply.send({assets}); + } + + async getById(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) { + const userId = getUserId(request); + const asset = await this.assetService.getById(request.params.id, userId); + return reply.send({asset}); + } + + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createAssetSchema.parse(request.body); + const asset = await this.assetService.create(userId, data); + return reply.status(201).send({asset}); + } + + async update(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) { + const userId = getUserId(request); + const data = updateAssetSchema.parse(request.body); + const asset = await this.assetService.update(request.params.id, userId, data); + return reply.send({asset}); + } + + async delete(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) { + const userId = getUserId(request); + await this.assetService.delete(request.params.id, userId); + return reply.status(204).send(); + } +} diff --git a/backend-api/src/controllers/AuthController.ts b/backend-api/src/controllers/AuthController.ts new file mode 100644 index 0000000..d08490d --- /dev/null +++ b/backend-api/src/controllers/AuthController.ts @@ -0,0 +1,72 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {z} from 'zod'; +import {AuthService} from '../services/AuthService'; +import {UserRepository} from '../repositories/UserRepository'; +import {DebtCategoryService} from '../services/DebtCategoryService'; +import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository'; + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + name: z.string().min(1), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +/** + * Auth Controller + * Implements Single Responsibility: Handles authentication HTTP requests + * Implements Dependency Inversion: Depends on AuthService + */ +export class AuthController { + private authService: AuthService; + + constructor() { + const userRepository = new UserRepository(); + const debtCategoryRepository = new DebtCategoryRepository(); + const debtCategoryService = new DebtCategoryService(debtCategoryRepository); + this.authService = new AuthService(userRepository, debtCategoryService); + } + + async register(request: FastifyRequest, reply: FastifyReply) { + const data = registerSchema.parse(request.body); + const user = await this.authService.register(data.email, data.password, data.name); + + const token = request.server.jwt.sign({ + id: user.id, + email: user.email, + }); + + return reply.status(201).send({ + user, + token, + }); + } + + async login(request: FastifyRequest, reply: FastifyReply) { + const data = loginSchema.parse(request.body); + const user = await this.authService.login(data.email, data.password); + + const token = request.server.jwt.sign({ + id: user.id, + email: user.email, + }); + + const {password: _, ...userWithoutPassword} = user; + + return reply.send({ + user: userWithoutPassword, + token, + }); + } + + async getProfile(request: FastifyRequest, reply: FastifyReply) { + const userId = request.user!.id; + const user = await this.authService.getUserById(userId); + + return reply.send({user}); + } +} diff --git a/backend-api/src/controllers/CashflowController.ts b/backend-api/src/controllers/CashflowController.ts new file mode 100644 index 0000000..f4cccad --- /dev/null +++ b/backend-api/src/controllers/CashflowController.ts @@ -0,0 +1,192 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {CashflowService} from '../services/CashflowService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const createIncomeSchema = z.object({ + name: z.string().min(1).max(255), + amount: z.number().min(0.01), + frequency: z.string(), + notes: z.string().optional(), +}); + +const updateIncomeSchema = createIncomeSchema.partial(); + +const createExpenseSchema = z.object({ + name: z.string().min(1).max(255), + amount: z.number().min(0.01), + category: z.string(), + frequency: z.string(), + dueDate: z.string().transform(str => new Date(str)).optional(), + notes: z.string().optional(), +}); + +const updateExpenseSchema = createExpenseSchema.partial(); + +const createTransactionSchema = z.object({ + type: z.string(), + category: z.string(), + amount: z.number().min(0.01), + date: z.string().transform(str => new Date(str)), + description: z.string().optional(), + notes: z.string().optional(), +}); + +/** + * Controller for Cashflow endpoints + */ +export class CashflowController { + constructor(private cashflowService: CashflowService) {} + + // Income Source endpoints + async createIncome(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createIncomeSchema.parse(request.body); + const income = await this.cashflowService.createIncome(userId, data); + return reply.status(201).send({income}); + } + + async getAllIncome(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const income = await this.cashflowService.getAllIncome(userId); + return reply.send({income}); + } + + async getOneIncome(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const income = await this.cashflowService.getIncomeById(id, userId); + return reply.send({income}); + } + + async updateIncome(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const data = updateIncomeSchema.parse(request.body); + const income = await this.cashflowService.updateIncome(id, userId, data); + return reply.send({income}); + } + + async deleteIncome(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + await this.cashflowService.deleteIncome(id, userId); + return reply.status(204).send(); + } + + async getTotalMonthlyIncome(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const total = await this.cashflowService.getTotalMonthlyIncome(userId); + return reply.send({total}); + } + + // Expense endpoints + async createExpense(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createExpenseSchema.parse(request.body); + const expense = await this.cashflowService.createExpense(userId, data); + return reply.status(201).send({expense}); + } + + async getAllExpenses(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {byCategory} = request.query as {byCategory?: string}; + + if (byCategory === 'true') { + const expenses = await this.cashflowService.getExpensesByCategory(userId); + return reply.send({expenses}); + } + + const expenses = await this.cashflowService.getAllExpenses(userId); + return reply.send({expenses}); + } + + async getOneExpense(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const expense = await this.cashflowService.getExpenseById(id, userId); + return reply.send({expense}); + } + + async updateExpense(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const data = updateExpenseSchema.parse(request.body); + const expense = await this.cashflowService.updateExpense(id, userId, data); + return reply.send({expense}); + } + + async deleteExpense(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + await this.cashflowService.deleteExpense(id, userId); + return reply.status(204).send(); + } + + async getTotalMonthlyExpenses(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const total = await this.cashflowService.getTotalMonthlyExpenses(userId); + return reply.send({total}); + } + + // Transaction endpoints + async createTransaction(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createTransactionSchema.parse(request.body); + const transaction = await this.cashflowService.createTransaction(userId, data); + return reply.status(201).send({transaction}); + } + + async getAllTransactions(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {type, startDate, endDate} = request.query as { + type?: string; + startDate?: string; + endDate?: string; + }; + + if (type) { + const transactions = await this.cashflowService.getTransactionsByType(userId, type); + return reply.send({transactions}); + } + + if (startDate && endDate) { + const transactions = await this.cashflowService.getTransactionsByDateRange( + userId, + new Date(startDate), + new Date(endDate) + ); + return reply.send({transactions}); + } + + const transactions = await this.cashflowService.getAllTransactions(userId); + return reply.send({transactions}); + } + + async getOneTransaction(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const transaction = await this.cashflowService.getTransactionById(id, userId); + return reply.send({transaction}); + } + + async deleteTransaction(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + await this.cashflowService.deleteTransaction(id, userId); + return reply.status(204).send(); + } + + async getCashflowSummary(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {startDate, endDate} = request.query as {startDate: string; endDate: string}; + + const summary = await this.cashflowService.getCashflowSummary( + userId, + new Date(startDate), + new Date(endDate) + ); + + return reply.send(summary); + } +} diff --git a/backend-api/src/controllers/ClientController.ts b/backend-api/src/controllers/ClientController.ts new file mode 100644 index 0000000..f3e5c0a --- /dev/null +++ b/backend-api/src/controllers/ClientController.ts @@ -0,0 +1,103 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {ClientService} from '../services/ClientService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const createClientSchema = z.object({ + name: z.string().min(1).max(255), + email: z.string().email(), + phone: z.string().max(50).optional(), + address: z.string().optional(), + notes: z.string().optional(), +}); + +const updateClientSchema = z.object({ + name: z.string().min(1).max(255).optional(), + email: z.string().email().optional(), + phone: z.string().max(50).optional(), + address: z.string().optional(), + notes: z.string().optional(), +}); + +/** + * Controller for Client endpoints + * Implements Single Responsibility Principle - handles only HTTP layer + */ +export class ClientController { + constructor(private clientService: ClientService) {} + + /** + * Create a new client + */ + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createClientSchema.parse(request.body); + + const client = await this.clientService.create(userId, data); + + return reply.status(201).send({client}); + } + + /** + * Get all clients for the authenticated user + */ + async getAll(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {withStats} = request.query as {withStats?: string}; + + if (withStats === 'true') { + const clients = await this.clientService.getWithStats(userId); + return reply.send({clients}); + } + + const clients = await this.clientService.getAllByUser(userId); + return reply.send({clients}); + } + + /** + * Get a single client by ID + */ + async getOne(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + const client = await this.clientService.getById(id, userId); + + return reply.send({client}); + } + + /** + * Update a client + */ + async update(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const data = updateClientSchema.parse(request.body); + + const client = await this.clientService.update(id, userId, data); + + return reply.send({client}); + } + + /** + * Delete a client + */ + async delete(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + await this.clientService.delete(id, userId); + + return reply.status(204).send(); + } + + /** + * Get total revenue from all clients + */ + async getTotalRevenue(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const totalRevenue = await this.clientService.getTotalRevenue(userId); + + return reply.send({totalRevenue}); + } +} diff --git a/backend-api/src/controllers/DashboardController.ts b/backend-api/src/controllers/DashboardController.ts new file mode 100644 index 0000000..4414f3f --- /dev/null +++ b/backend-api/src/controllers/DashboardController.ts @@ -0,0 +1,17 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {DashboardService} from '../services/DashboardService'; +import {getUserId} from '../middleware/auth'; + +/** + * Controller for Dashboard endpoints + */ +export class DashboardController { + constructor(private dashboardService: DashboardService) {} + + async getSummary(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const summary = await this.dashboardService.getSummary(userId); + + return reply.send(summary); + } +} diff --git a/backend-api/src/controllers/DebtAccountController.ts b/backend-api/src/controllers/DebtAccountController.ts new file mode 100644 index 0000000..fb79f51 --- /dev/null +++ b/backend-api/src/controllers/DebtAccountController.ts @@ -0,0 +1,116 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {DebtAccountService} from '../services/DebtAccountService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const createAccountSchema = z.object({ + categoryId: z.string().uuid(), + name: z.string().min(1).max(255), + creditor: z.string().min(1).max(255), + accountNumber: z.string().max(100).optional(), + originalBalance: z.number().min(0), + currentBalance: z.number().min(0), + interestRate: z.number().min(0).max(100).optional(), + minimumPayment: z.number().min(0).optional(), + dueDate: z.string().transform(str => new Date(str)).optional(), + notes: z.string().optional(), +}); + +const updateAccountSchema = z.object({ + name: z.string().min(1).max(255).optional(), + creditor: z.string().min(1).max(255).optional(), + accountNumber: z.string().max(100).optional(), + currentBalance: z.number().min(0).optional(), + interestRate: z.number().min(0).max(100).optional(), + minimumPayment: z.number().min(0).optional(), + dueDate: z.string().transform(str => new Date(str)).optional(), + notes: z.string().optional(), +}); + +/** + * Controller for DebtAccount endpoints + * Implements Single Responsibility Principle - handles only HTTP layer + */ +export class DebtAccountController { + constructor(private accountService: DebtAccountService) {} + + /** + * Create a new debt account + */ + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createAccountSchema.parse(request.body); + + const account = await this.accountService.create(userId, data); + + return reply.status(201).send({account}); + } + + /** + * Get all debt accounts + */ + async getAll(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {withStats, categoryId} = request.query as {withStats?: string; categoryId?: string}; + + if (categoryId) { + const accounts = await this.accountService.getByCategory(categoryId, userId); + return reply.send({accounts}); + } + + if (withStats === 'true') { + const accounts = await this.accountService.getWithStats(userId); + return reply.send({accounts}); + } + + const accounts = await this.accountService.getAllByUser(userId); + return reply.send({accounts}); + } + + /** + * Get a single debt account + */ + async getOne(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + const account = await this.accountService.getById(id, userId); + + return reply.send({account}); + } + + /** + * Update a debt account + */ + async update(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const data = updateAccountSchema.parse(request.body); + + const account = await this.accountService.update(id, userId, data); + + return reply.send({account}); + } + + /** + * Delete a debt account + */ + async delete(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + await this.accountService.delete(id, userId); + + return reply.status(204).send(); + } + + /** + * Get total debt + */ + async getTotalDebt(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const totalDebt = await this.accountService.getTotalDebt(userId); + + return reply.send({totalDebt}); + } +} diff --git a/backend-api/src/controllers/DebtCategoryController.ts b/backend-api/src/controllers/DebtCategoryController.ts new file mode 100644 index 0000000..dfcecba --- /dev/null +++ b/backend-api/src/controllers/DebtCategoryController.ts @@ -0,0 +1,89 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {DebtCategoryService} from '../services/DebtCategoryService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const createCategorySchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(), +}); + +const updateCategorySchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().optional(), + color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(), +}); + +/** + * Controller for DebtCategory endpoints + * Implements Single Responsibility Principle - handles only HTTP layer + */ +export class DebtCategoryController { + constructor(private categoryService: DebtCategoryService) {} + + /** + * Create a new debt category + */ + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createCategorySchema.parse(request.body); + + const category = await this.categoryService.create(userId, data); + + return reply.status(201).send({category}); + } + + /** + * Get all debt categories + */ + async getAll(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {withStats} = request.query as {withStats?: string}; + + if (withStats === 'true') { + const categories = await this.categoryService.getWithStats(userId); + return reply.send({categories}); + } + + const categories = await this.categoryService.getAllByUser(userId); + return reply.send({categories}); + } + + /** + * Get a single debt category + */ + async getOne(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + const category = await this.categoryService.getById(id, userId); + + return reply.send({category}); + } + + /** + * Update a debt category + */ + async update(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const data = updateCategorySchema.parse(request.body); + + const category = await this.categoryService.update(id, userId, data); + + return reply.send({category}); + } + + /** + * Delete a debt category + */ + async delete(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + await this.categoryService.delete(id, userId); + + return reply.status(204).send(); + } +} diff --git a/backend-api/src/controllers/DebtPaymentController.ts b/backend-api/src/controllers/DebtPaymentController.ts new file mode 100644 index 0000000..7ae1712 --- /dev/null +++ b/backend-api/src/controllers/DebtPaymentController.ts @@ -0,0 +1,94 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {DebtPaymentService} from '../services/DebtPaymentService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const createPaymentSchema = z.object({ + accountId: z.string().uuid(), + amount: z.number().min(0.01), + paymentDate: z.string().transform(str => new Date(str)), + notes: z.string().optional(), +}); + +/** + * Controller for DebtPayment endpoints + * Implements Single Responsibility Principle - handles only HTTP layer + */ +export class DebtPaymentController { + constructor(private paymentService: DebtPaymentService) {} + + /** + * Create a new debt payment + */ + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createPaymentSchema.parse(request.body); + + const payment = await this.paymentService.create(userId, data); + + return reply.status(201).send({payment}); + } + + /** + * Get all debt payments + */ + async getAll(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {accountId, startDate, endDate} = request.query as { + accountId?: string; + startDate?: string; + endDate?: string; + }; + + if (accountId) { + const payments = await this.paymentService.getByAccount(accountId, userId); + return reply.send({payments}); + } + + if (startDate && endDate) { + const payments = await this.paymentService.getByDateRange( + userId, + new Date(startDate), + new Date(endDate) + ); + return reply.send({payments}); + } + + const payments = await this.paymentService.getAllByUser(userId); + return reply.send({payments}); + } + + /** + * Get a single debt payment + */ + async getOne(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + const payment = await this.paymentService.getById(id, userId); + + return reply.send({payment}); + } + + /** + * Delete a debt payment + */ + async delete(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + await this.paymentService.delete(id, userId); + + return reply.status(204).send(); + } + + /** + * Get total payments + */ + async getTotalPayments(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const totalPayments = await this.paymentService.getTotalPayments(userId); + + return reply.send({totalPayments}); + } +} diff --git a/backend-api/src/controllers/InvoiceController.ts b/backend-api/src/controllers/InvoiceController.ts new file mode 100644 index 0000000..7d30411 --- /dev/null +++ b/backend-api/src/controllers/InvoiceController.ts @@ -0,0 +1,137 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {InvoiceService} from '../services/InvoiceService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const lineItemSchema = z.object({ + description: z.string().min(1), + quantity: z.number().min(1), + unitPrice: z.number().min(0), + amount: z.number().min(0), +}); + +const createInvoiceSchema = z.object({ + clientId: z.string().uuid(), + issueDate: z.string().transform(str => new Date(str)), + dueDate: z.string().transform(str => new Date(str)), + lineItems: z.array(lineItemSchema).min(1), + notes: z.string().optional(), + terms: z.string().optional(), +}); + +const updateInvoiceSchema = z.object({ + issueDate: z.string().transform(str => new Date(str)).optional(), + dueDate: z.string().transform(str => new Date(str)).optional(), + lineItems: z.array(lineItemSchema).min(1).optional(), + notes: z.string().optional(), + terms: z.string().optional(), +}); + +const updateStatusSchema = z.object({ + status: z.enum(['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED']), +}); + +/** + * Controller for Invoice endpoints + * Implements Single Responsibility Principle - handles only HTTP layer + */ +export class InvoiceController { + constructor(private invoiceService: InvoiceService) {} + + /** + * Create a new invoice + */ + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createInvoiceSchema.parse(request.body); + + const invoice = await this.invoiceService.create(userId, data); + + return reply.status(201).send({invoice}); + } + + /** + * Get all invoices for the authenticated user + */ + async getAll(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {clientId, status} = request.query as {clientId?: string; status?: string}; + + const invoices = await this.invoiceService.getAllByUser(userId, { + clientId, + status, + }); + + return reply.send({invoices}); + } + + /** + * Get a single invoice by ID + */ + async getOne(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + const invoice = await this.invoiceService.getById(id, userId); + + return reply.send({invoice}); + } + + /** + * Update an invoice + */ + async update(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const data = updateInvoiceSchema.parse(request.body); + + const invoice = await this.invoiceService.update(id, userId, data); + + return reply.send({invoice}); + } + + /** + * Update invoice status + */ + async updateStatus(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const {status} = updateStatusSchema.parse(request.body); + + const invoice = await this.invoiceService.updateStatus(id, userId, status); + + return reply.send({invoice}); + } + + /** + * Delete an invoice + */ + async delete(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + await this.invoiceService.delete(id, userId); + + return reply.status(204).send(); + } + + /** + * Get invoice statistics + */ + async getStats(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const stats = await this.invoiceService.getStats(userId); + + return reply.send({stats}); + } + + /** + * Get overdue invoices + */ + async getOverdue(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const overdueInvoices = await this.invoiceService.getOverdueInvoices(userId); + + return reply.send({invoices: overdueInvoices}); + } +} diff --git a/backend-api/src/controllers/LiabilityController.ts b/backend-api/src/controllers/LiabilityController.ts new file mode 100644 index 0000000..a211e63 --- /dev/null +++ b/backend-api/src/controllers/LiabilityController.ts @@ -0,0 +1,113 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {LiabilityService} from '../services/LiabilityService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const createLiabilitySchema = z.object({ + name: z.string().min(1).max(255), + type: z.string().min(1), + currentBalance: z.number().min(0), + interestRate: z.number().min(0).max(100).optional(), + minimumPayment: z.number().min(0).optional(), + dueDate: z.string().transform(str => new Date(str)).optional(), + creditor: z.string().max(255).optional(), + notes: z.string().optional(), +}); + +const updateLiabilitySchema = z.object({ + name: z.string().min(1).max(255).optional(), + type: z.string().min(1).optional(), + currentBalance: z.number().min(0).optional(), + interestRate: z.number().min(0).max(100).optional(), + minimumPayment: z.number().min(0).optional(), + dueDate: z.string().transform(str => new Date(str)).optional(), + creditor: z.string().max(255).optional(), + notes: z.string().optional(), +}); + +/** + * Controller for Liability endpoints + * Implements Single Responsibility Principle - handles only HTTP layer + */ +export class LiabilityController { + constructor(private liabilityService: LiabilityService) {} + + /** + * Create a new liability + */ + async create(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createLiabilitySchema.parse(request.body); + + const liability = await this.liabilityService.create(userId, data); + + return reply.status(201).send({liability}); + } + + /** + * Get all liabilities for the authenticated user + */ + async getAll(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const liabilities = await this.liabilityService.getAllByUser(userId); + + return reply.send({liabilities}); + } + + /** + * Get a single liability by ID + */ + async getOne(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + const liability = await this.liabilityService.getById(id, userId); + + return reply.send({liability}); + } + + /** + * Update a liability + */ + async update(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + const data = updateLiabilitySchema.parse(request.body); + + const liability = await this.liabilityService.update(id, userId, data); + + return reply.send({liability}); + } + + /** + * Delete a liability + */ + async delete(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + await this.liabilityService.delete(id, userId); + + return reply.status(204).send(); + } + + /** + * Get total liability value + */ + async getTotalValue(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const totalValue = await this.liabilityService.getTotalValue(userId); + + return reply.send({totalValue}); + } + + /** + * Get liabilities grouped by type + */ + async getByType(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const liabilitiesByType = await this.liabilityService.getByType(userId); + + return reply.send({liabilitiesByType}); + } +} diff --git a/backend-api/src/controllers/NetWorthController.ts b/backend-api/src/controllers/NetWorthController.ts new file mode 100644 index 0000000..9e36c75 --- /dev/null +++ b/backend-api/src/controllers/NetWorthController.ts @@ -0,0 +1,129 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {NetWorthService} from '../services/NetWorthService'; +import {getUserId} from '../middleware/auth'; +import {z} from 'zod'; + +const createSnapshotSchema = z.object({ + date: z.string().transform(str => new Date(str)), + totalAssets: z.number().min(0), + totalLiabilities: z.number().min(0), + netWorth: z.number(), + notes: z.string().optional(), +}); + +const createFromCurrentSchema = z.object({ + notes: z.string().optional(), +}); + +const dateRangeSchema = z.object({ + startDate: z.string().transform(str => new Date(str)), + endDate: z.string().transform(str => new Date(str)), +}); + +/** + * Controller for Net Worth endpoints + * Implements Single Responsibility Principle - handles only HTTP layer + */ +export class NetWorthController { + constructor(private netWorthService: NetWorthService) {} + + /** + * Get current net worth + */ + async getCurrent(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const current = await this.netWorthService.getCurrentNetWorth(userId); + + return reply.send(current); + } + + /** + * Get all snapshots + */ + async getAllSnapshots(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const snapshots = await this.netWorthService.getAllSnapshots(userId); + + return reply.send({snapshots}); + } + + /** + * Get snapshots by date range + */ + async getByDateRange(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {startDate, endDate} = request.query as {startDate: string; endDate: string}; + + const parsed = dateRangeSchema.parse({startDate, endDate}); + const snapshots = await this.netWorthService.getSnapshotsByDateRange( + userId, + parsed.startDate, + parsed.endDate + ); + + return reply.send({snapshots}); + } + + /** + * Create a manual snapshot + */ + async createSnapshot(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const data = createSnapshotSchema.parse(request.body); + + const snapshot = await this.netWorthService.createSnapshot(userId, data); + + return reply.status(201).send({snapshot}); + } + + /** + * Create snapshot from current assets and liabilities + */ + async createFromCurrent(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {notes} = createFromCurrentSchema.parse(request.body); + + const snapshot = await this.netWorthService.createFromCurrent(userId, notes); + + return reply.status(201).send({snapshot}); + } + + /** + * Get a single snapshot + */ + async getOne(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + const snapshot = await this.netWorthService.getById(id, userId); + + return reply.send({snapshot}); + } + + /** + * Delete a snapshot + */ + async delete(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {id} = request.params as {id: string}; + + await this.netWorthService.delete(id, userId); + + return reply.status(204).send(); + } + + /** + * Get growth statistics + */ + async getGrowthStats(request: FastifyRequest, reply: FastifyReply) { + const userId = getUserId(request); + const {limit} = request.query as {limit?: string}; + + const stats = await this.netWorthService.getGrowthStats( + userId, + limit ? parseInt(limit) : undefined + ); + + return reply.send({stats}); + } +} diff --git a/backend-api/src/index.ts b/backend-api/src/index.ts new file mode 100644 index 0000000..64aa494 --- /dev/null +++ b/backend-api/src/index.ts @@ -0,0 +1,37 @@ +import {buildServer} from './server'; +import {env} from './config/env'; +import {DatabaseConnection} from './config/database'; + +/** + * Application entry point + */ +async function main() { + try { + const server = await buildServer(); + + // Start server + await server.listen({ + port: env.PORT, + host: '0.0.0.0', + }); + + server.log.info(`🚀 Server listening on http://localhost:${env.PORT}`); + server.log.info(`📚 API Documentation available at http://localhost:${env.PORT}/docs`); + + // Graceful shutdown + const signals = ['SIGINT', 'SIGTERM']; + signals.forEach(signal => { + process.on(signal, async () => { + server.log.info(`${signal} received, shutting down gracefully...`); + await server.close(); + await DatabaseConnection.disconnect(); + process.exit(0); + }); + }); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +main(); diff --git a/backend-api/src/middleware/auth.ts b/backend-api/src/middleware/auth.ts new file mode 100644 index 0000000..1f86e6a --- /dev/null +++ b/backend-api/src/middleware/auth.ts @@ -0,0 +1,36 @@ +import {FastifyRequest, FastifyReply} from 'fastify'; +import {UnauthorizedError} from '../utils/errors'; + +/** + * Extend Fastify Request with user property + */ +declare module 'fastify' { + interface FastifyRequest { + user?: { + id: string; + email: string; + }; + } +} + +/** + * Authentication Middleware + * Verifies JWT token and attaches user to request + */ +export async function authenticate(request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify(); + } catch (err) { + throw new UnauthorizedError('Invalid or expired token'); + } +} + +/** + * Extract user ID from authenticated request + */ +export function getUserId(request: FastifyRequest): string { + if (!request.user || !request.user.id) { + throw new UnauthorizedError('User not authenticated'); + } + return request.user.id; +} diff --git a/backend-api/src/middleware/errorHandler.ts b/backend-api/src/middleware/errorHandler.ts new file mode 100644 index 0000000..9aa367d --- /dev/null +++ b/backend-api/src/middleware/errorHandler.ts @@ -0,0 +1,64 @@ +import {FastifyError, FastifyReply, FastifyRequest} from 'fastify'; +import {AppError} from '../utils/errors'; +import {ZodError} from 'zod'; + +/** + * Global Error Handler + * Implements Single Responsibility: Handles all error responses + */ +export async function errorHandler(error: FastifyError, request: FastifyRequest, reply: FastifyReply) { + // Log error + request.log.error(error); + + // Handle custom app errors + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + error: error.name, + message: error.message, + }); + } + + // Handle Zod validation errors + if (error instanceof ZodError) { + return reply.status(400).send({ + error: 'ValidationError', + message: 'Invalid request data', + details: error.errors, + }); + } + + // Handle Fastify validation errors + if (error.validation) { + return reply.status(400).send({ + error: 'ValidationError', + message: error.message, + details: error.validation, + }); + } + + // Handle Prisma errors + if (error.name === 'PrismaClientKnownRequestError') { + const prismaError = error as any; + if (prismaError.code === 'P2002') { + return reply.status(409).send({ + error: 'ConflictError', + message: 'A record with this value already exists', + }); + } + if (prismaError.code === 'P2025') { + return reply.status(404).send({ + error: 'NotFoundError', + message: 'Record not found', + }); + } + } + + // Default server error + const statusCode = error.statusCode || 500; + const message = statusCode === 500 ? 'Internal server error' : error.message; + + return reply.status(statusCode).send({ + error: 'ServerError', + message, + }); +} diff --git a/backend-api/src/repositories/AssetRepository.ts b/backend-api/src/repositories/AssetRepository.ts new file mode 100644 index 0000000..1774483 --- /dev/null +++ b/backend-api/src/repositories/AssetRepository.ts @@ -0,0 +1,49 @@ +import {Asset, Prisma} from '@prisma/client'; +import {prisma} from '../config/database'; +import {IUserScopedRepository} from './interfaces/IRepository'; + +/** + * Asset Repository + * Implements Single Responsibility: Only handles Asset data access + */ +export class AssetRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.asset.findUnique({where: {id}}); + } + + async findByIdAndUser(id: string, userId: string): Promise { + return prisma.asset.findFirst({ + where: {id, userId}, + }); + } + + async findAllByUser(userId: string, filters?: Record): Promise { + return prisma.asset.findMany({ + where: {userId, ...filters}, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.AssetCreateInput): Promise { + return prisma.asset.create({data}); + } + + async update(id: string, data: Prisma.AssetUpdateInput): Promise { + return prisma.asset.update({ + where: {id}, + data, + }); + } + + async delete(id: string): Promise { + await prisma.asset.delete({where: {id}}); + } + + async getTotalValue(userId: string): Promise { + const result = await prisma.asset.aggregate({ + where: {userId}, + _sum: {value: true}, + }); + return result._sum.value || 0; + } +} diff --git a/backend-api/src/repositories/CashflowRepository.ts b/backend-api/src/repositories/CashflowRepository.ts new file mode 100644 index 0000000..98c51d4 --- /dev/null +++ b/backend-api/src/repositories/CashflowRepository.ts @@ -0,0 +1,149 @@ +import {IncomeSource, Expense, Transaction, Prisma} from '@prisma/client'; +import {DatabaseConnection} from '../config/database'; +import {IUserScopedRepository} from './interfaces/IRepository'; + +const prisma = DatabaseConnection.getInstance(); + +/** + * Repository for IncomeSource data access + */ +export class IncomeSourceRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.incomeSource.findUnique({where: {id}}); + } + + async findAllByUser(userId: string): Promise { + return prisma.incomeSource.findMany({ + where: {userId}, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.IncomeSourceCreateInput): Promise { + return prisma.incomeSource.create({data}); + } + + async update(id: string, data: Prisma.IncomeSourceUpdateInput): Promise { + return prisma.incomeSource.update({where: {id}, data}); + } + + async delete(id: string): Promise { + await prisma.incomeSource.delete({where: {id}}); + } + + async getTotalMonthlyIncome(userId: string): Promise { + const result = await prisma.incomeSource.aggregate({ + where: {userId}, + _sum: {amount: true}, + }); + return result._sum.amount || 0; + } +} + +/** + * Repository for Expense data access + */ +export class ExpenseRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.expense.findUnique({where: {id}}); + } + + async findAllByUser(userId: string): Promise { + return prisma.expense.findMany({ + where: {userId}, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.ExpenseCreateInput): Promise { + return prisma.expense.create({data}); + } + + async update(id: string, data: Prisma.ExpenseUpdateInput): Promise { + return prisma.expense.update({where: {id}, data}); + } + + async delete(id: string): Promise { + await prisma.expense.delete({where: {id}}); + } + + async getTotalMonthlyExpenses(userId: string): Promise { + const result = await prisma.expense.aggregate({ + where: {userId}, + _sum: {amount: true}, + }); + return result._sum.amount || 0; + } + + async getByCategory(userId: string): Promise> { + const expenses = await this.findAllByUser(userId); + return expenses.reduce((acc, expense) => { + if (!acc[expense.category]) acc[expense.category] = []; + acc[expense.category].push(expense); + return acc; + }, {} as Record); + } +} + +/** + * Repository for Transaction data access + */ +export class TransactionRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.transaction.findUnique({where: {id}}); + } + + async findAllByUser(userId: string): Promise { + return prisma.transaction.findMany({ + where: {userId}, + orderBy: {date: 'desc'}, + }); + } + + async create(data: Prisma.TransactionCreateInput): Promise { + return prisma.transaction.create({data}); + } + + async delete(id: string): Promise { + await prisma.transaction.delete({where: {id}}); + } + + async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise { + return prisma.transaction.findMany({ + where: { + userId, + date: {gte: startDate, lte: endDate}, + }, + orderBy: {date: 'desc'}, + }); + } + + async getByType(userId: string, type: string): Promise { + return prisma.transaction.findMany({ + where: {userId, type}, + orderBy: {date: 'desc'}, + }); + } + + async getCashflowSummary(userId: string, startDate: Date, endDate: Date): Promise<{ + totalIncome: number; + totalExpenses: number; + netCashflow: number; + }> { + const transactions = await this.getByDateRange(userId, startDate, endDate); + + const totalIncome = transactions + .filter(t => t.type === 'INCOME') + .reduce((sum, t) => sum + t.amount, 0); + + const totalExpenses = transactions + .filter(t => t.type === 'EXPENSE') + .reduce((sum, t) => sum + t.amount, 0); + + return { + totalIncome, + totalExpenses, + netCashflow: totalIncome - totalExpenses, + }; + } +} diff --git a/backend-api/src/repositories/ClientRepository.ts b/backend-api/src/repositories/ClientRepository.ts new file mode 100644 index 0000000..07c702a --- /dev/null +++ b/backend-api/src/repositories/ClientRepository.ts @@ -0,0 +1,121 @@ +import {Client, Prisma} from '@prisma/client'; +import {DatabaseConnection} from '../config/database'; +import {IUserScopedRepository} from './interfaces/IRepository'; + +const prisma = DatabaseConnection.getInstance(); + +/** + * Repository for Client data access + * Implements Single Responsibility Principle - handles only database operations + */ +export class ClientRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.client.findUnique({ + where: {id}, + include: { + invoices: true, + }, + }); + } + + async findAllByUser(userId: string): Promise { + return prisma.client.findMany({ + where: {userId}, + include: { + invoices: { + orderBy: {createdAt: 'desc'}, + }, + }, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.ClientCreateInput): Promise { + return prisma.client.create({ + data, + include: { + invoices: true, + }, + }); + } + + async update(id: string, data: Prisma.ClientUpdateInput): Promise { + return prisma.client.update({ + where: {id}, + data, + include: { + invoices: true, + }, + }); + } + + async delete(id: string): Promise { + await prisma.client.delete({ + where: {id}, + }); + } + + /** + * Find client by email + */ + async findByEmail(userId: string, email: string): Promise { + return prisma.client.findFirst({ + where: { + userId, + email, + }, + }); + } + + /** + * Get total revenue from all clients + */ + async getTotalRevenue(userId: string): Promise { + const result = await prisma.invoice.aggregate({ + where: { + client: { + userId, + }, + status: 'PAID', + }, + _sum: { + total: true, + }, + }); + + return result._sum.total || 0; + } + + /** + * Get clients with their invoice statistics + */ + async getWithStats(userId: string): Promise { + const clients = await prisma.client.findMany({ + where: {userId}, + include: { + invoices: { + select: { + id: true, + total: true, + status: true, + }, + }, + }, + orderBy: {createdAt: 'desc'}, + }); + + return clients.map(client => ({ + ...client, + stats: { + totalInvoices: client.invoices.length, + paidInvoices: client.invoices.filter(inv => inv.status === 'PAID').length, + totalRevenue: client.invoices + .filter(inv => inv.status === 'PAID') + .reduce((sum, inv) => sum + inv.total, 0), + outstandingAmount: client.invoices + .filter(inv => inv.status !== 'PAID') + .reduce((sum, inv) => sum + inv.total, 0), + }, + })); + } +} diff --git a/backend-api/src/repositories/DebtAccountRepository.ts b/backend-api/src/repositories/DebtAccountRepository.ts new file mode 100644 index 0000000..dca671c --- /dev/null +++ b/backend-api/src/repositories/DebtAccountRepository.ts @@ -0,0 +1,118 @@ +import {DebtAccount, Prisma} from '@prisma/client'; +import {DatabaseConnection} from '../config/database'; + +const prisma = DatabaseConnection.getInstance(); + +/** + * Repository for DebtAccount data access + * Implements Single Responsibility Principle - handles only database operations + */ +export class DebtAccountRepository { + async findById(id: string): Promise { + return prisma.debtAccount.findUnique({ + where: {id}, + include: { + category: true, + payments: { + orderBy: {paymentDate: 'desc'}, + }, + }, + }); + } + + async findAllByUser(userId: string): Promise { + return prisma.debtAccount.findMany({ + where: { + category: { + userId, + }, + }, + include: { + category: true, + payments: { + orderBy: {paymentDate: 'desc'}, + }, + }, + orderBy: {createdAt: 'desc'}, + }); + } + + async findByCategory(categoryId: string): Promise { + return prisma.debtAccount.findMany({ + where: {categoryId}, + include: { + payments: { + orderBy: {paymentDate: 'desc'}, + }, + }, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.DebtAccountCreateInput): Promise { + return prisma.debtAccount.create({ + data, + include: { + category: true, + payments: true, + }, + }); + } + + async update(id: string, data: Prisma.DebtAccountUpdateInput): Promise { + return prisma.debtAccount.update({ + where: {id}, + data, + include: { + category: true, + payments: true, + }, + }); + } + + async delete(id: string): Promise { + await prisma.debtAccount.delete({ + where: {id}, + }); + } + + /** + * Get total debt across all accounts for a user + */ + async getTotalDebt(userId: string): Promise { + const result = await prisma.debtAccount.aggregate({ + where: { + category: { + userId, + }, + }, + _sum: { + currentBalance: true, + }, + }); + + return result._sum.currentBalance || 0; + } + + /** + * Get accounts with payment statistics + */ + async getWithStats(userId: string): Promise { + const accounts = await this.findAllByUser(userId); + + return accounts.map(account => { + const totalPaid = account.payments.reduce((sum, payment) => sum + payment.amount, 0); + const lastPayment = account.payments[0]; + + return { + ...account, + stats: { + totalPaid, + numberOfPayments: account.payments.length, + lastPaymentDate: lastPayment?.paymentDate || null, + lastPaymentAmount: lastPayment?.amount || null, + }, + }; + }); + } +} diff --git a/backend-api/src/repositories/DebtCategoryRepository.ts b/backend-api/src/repositories/DebtCategoryRepository.ts new file mode 100644 index 0000000..446b040 --- /dev/null +++ b/backend-api/src/repositories/DebtCategoryRepository.ts @@ -0,0 +1,117 @@ +import {DebtCategory, Prisma} from '@prisma/client'; +import {DatabaseConnection} from '../config/database'; +import {IUserScopedRepository} from './interfaces/IRepository'; + +const prisma = DatabaseConnection.getInstance(); + +/** + * Repository for DebtCategory data access + * Implements Single Responsibility Principle - handles only database operations + */ +export class DebtCategoryRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.debtCategory.findUnique({ + where: {id}, + include: { + accounts: { + include: { + payments: true, + }, + }, + }, + }); + } + + async findAllByUser(userId: string): Promise { + return prisma.debtCategory.findMany({ + where: {userId}, + include: { + accounts: { + include: { + payments: true, + }, + orderBy: {createdAt: 'desc'}, + }, + }, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.DebtCategoryCreateInput): Promise { + return prisma.debtCategory.create({ + data, + include: { + accounts: true, + }, + }); + } + + async update(id: string, data: Prisma.DebtCategoryUpdateInput): Promise { + return prisma.debtCategory.update({ + where: {id}, + data, + include: { + accounts: true, + }, + }); + } + + async delete(id: string): Promise { + await prisma.debtCategory.delete({ + where: {id}, + }); + } + + /** + * Find category by name + */ + async findByName(userId: string, name: string): Promise { + return prisma.debtCategory.findFirst({ + where: { + userId, + name, + }, + }); + } + + /** + * Get total debt across all accounts in a category + */ + async getTotalDebt(categoryId: string): Promise { + const result = await prisma.debtAccount.aggregate({ + where: {categoryId}, + _sum: { + currentBalance: true, + }, + }); + + return result._sum.currentBalance || 0; + } + + /** + * Get categories with debt statistics + */ + async getWithStats(userId: string): Promise { + const categories = await this.findAllByUser(userId); + + return Promise.all( + categories.map(async category => { + const totalDebt = await this.getTotalDebt(category.id); + const totalPayments = category.accounts.reduce( + (sum, account) => + sum + account.payments.reduce((pSum, payment) => pSum + payment.amount, 0), + 0 + ); + + return { + ...category, + stats: { + totalAccounts: category.accounts.length, + totalDebt, + totalPayments, + }, + }; + }) + ); + } +} diff --git a/backend-api/src/repositories/DebtPaymentRepository.ts b/backend-api/src/repositories/DebtPaymentRepository.ts new file mode 100644 index 0000000..c9d8932 --- /dev/null +++ b/backend-api/src/repositories/DebtPaymentRepository.ts @@ -0,0 +1,130 @@ +import {DebtPayment, Prisma} from '@prisma/client'; +import {DatabaseConnection} from '../config/database'; + +const prisma = DatabaseConnection.getInstance(); + +/** + * Repository for DebtPayment data access + * Implements Single Responsibility Principle - handles only database operations + */ +export class DebtPaymentRepository { + async findById(id: string): Promise { + return prisma.debtPayment.findUnique({ + where: {id}, + include: { + account: { + include: { + category: true, + }, + }, + }, + }); + } + + async findByAccount(accountId: string): Promise { + return prisma.debtPayment.findMany({ + where: {accountId}, + orderBy: {paymentDate: 'desc'}, + }); + } + + async findAllByUser(userId: string): Promise { + return prisma.debtPayment.findMany({ + where: { + account: { + category: { + userId, + }, + }, + }, + include: { + account: { + include: { + category: true, + }, + }, + }, + orderBy: {paymentDate: 'desc'}, + }); + } + + async create(data: Prisma.DebtPaymentCreateInput): Promise { + return prisma.debtPayment.create({ + data, + include: { + account: { + include: { + category: true, + }, + }, + }, + }); + } + + async delete(id: string): Promise { + await prisma.debtPayment.delete({ + where: {id}, + }); + } + + /** + * Get total payments for an account + */ + async getTotalPayments(accountId: string): Promise { + const result = await prisma.debtPayment.aggregate({ + where: {accountId}, + _sum: { + amount: true, + }, + }); + + return result._sum.amount || 0; + } + + /** + * Get total payments for a user + */ + async getTotalPaymentsByUser(userId: string): Promise { + const result = await prisma.debtPayment.aggregate({ + where: { + account: { + category: { + userId, + }, + }, + }, + _sum: { + amount: true, + }, + }); + + return result._sum.amount || 0; + } + + /** + * Get payments within a date range + */ + async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise { + return prisma.debtPayment.findMany({ + where: { + account: { + category: { + userId, + }, + }, + paymentDate: { + gte: startDate, + lte: endDate, + }, + }, + include: { + account: { + include: { + category: true, + }, + }, + }, + orderBy: {paymentDate: 'desc'}, + }); + } +} diff --git a/backend-api/src/repositories/InvoiceRepository.ts b/backend-api/src/repositories/InvoiceRepository.ts new file mode 100644 index 0000000..9bb7f0a --- /dev/null +++ b/backend-api/src/repositories/InvoiceRepository.ts @@ -0,0 +1,76 @@ +import {Invoice, Prisma, InvoiceStatus} from '@prisma/client'; +import {prisma} from '../config/database'; +import {IUserScopedRepository} from './interfaces/IRepository'; + +type InvoiceWithLineItems = Prisma.InvoiceGetPayload<{ + include: {lineItems: true; client: true}; +}>; + +/** + * Invoice Repository + * Handles Invoice data access with relationships + */ +export class InvoiceRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.invoice.findUnique({ + where: {id}, + 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}, + }); + } + + async findAllByUser(userId: string, filters?: {status?: InvoiceStatus}): Promise { + return prisma.invoice.findMany({ + where: {userId, ...filters}, + include: {lineItems: true, client: true}, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.InvoiceCreateInput): Promise { + return prisma.invoice.create({ + data, + include: {lineItems: true, client: true}, + }) as unknown as Invoice; + } + + async update(id: string, data: Prisma.InvoiceUpdateInput): Promise { + return prisma.invoice.update({ + where: {id}, + data, + include: {lineItems: true, client: true}, + }) as unknown as Invoice; + } + + async delete(id: string): Promise { + await prisma.invoice.delete({where: {id}}); + } + + async invoiceNumberExists(userId: string, invoiceNumber: string, excludeId?: string): Promise { + const count = await prisma.invoice.count({ + where: { + userId, + invoiceNumber, + ...(excludeId && {id: {not: excludeId}}), + }, + }); + return count > 0; + } + + async generateInvoiceNumber(userId: string): Promise { + const year = new Date().getFullYear(); + const count = await prisma.invoice.count({ + where: { + userId, + invoiceNumber: {startsWith: `INV-${year}-`}, + }, + }); + return `INV-${year}-${String(count + 1).padStart(3, '0')}`; + } +} diff --git a/backend-api/src/repositories/LiabilityRepository.ts b/backend-api/src/repositories/LiabilityRepository.ts new file mode 100644 index 0000000..3b8a680 --- /dev/null +++ b/backend-api/src/repositories/LiabilityRepository.ts @@ -0,0 +1,73 @@ +import {Liability, Prisma} from '@prisma/client'; +import {DatabaseConnection} from '../config/database'; +import {IUserScopedRepository} from './interfaces/IRepository'; + +const prisma = DatabaseConnection.getInstance(); + +/** + * Repository for Liability data access + * Implements Single Responsibility Principle - handles only database operations + */ +export class LiabilityRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.liability.findUnique({ + where: {id}, + }); + } + + async findAllByUser(userId: string): Promise { + return prisma.liability.findMany({ + where: {userId}, + orderBy: {createdAt: 'desc'}, + }); + } + + async create(data: Prisma.LiabilityCreateInput): Promise { + return prisma.liability.create({ + data, + }); + } + + async update(id: string, data: Prisma.LiabilityUpdateInput): Promise { + return prisma.liability.update({ + where: {id}, + data, + }); + } + + async delete(id: string): Promise { + await prisma.liability.delete({ + where: {id}, + }); + } + + /** + * Get total value of all liabilities for a user + */ + async getTotalValue(userId: string): Promise { + const result = await prisma.liability.aggregate({ + where: {userId}, + _sum: { + currentBalance: true, + }, + }); + + return result._sum.currentBalance || 0; + } + + /** + * Get liabilities grouped by type + */ + async getByType(userId: string): Promise> { + 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); + } +} diff --git a/backend-api/src/repositories/NetWorthSnapshotRepository.ts b/backend-api/src/repositories/NetWorthSnapshotRepository.ts new file mode 100644 index 0000000..295d06c --- /dev/null +++ b/backend-api/src/repositories/NetWorthSnapshotRepository.ts @@ -0,0 +1,112 @@ +import {NetWorthSnapshot, Prisma} from '@prisma/client'; +import {DatabaseConnection} from '../config/database'; +import {IUserScopedRepository} from './interfaces/IRepository'; + +const prisma = DatabaseConnection.getInstance(); + +/** + * Repository for NetWorthSnapshot data access + * Implements Single Responsibility Principle - handles only database operations + */ +export class NetWorthSnapshotRepository implements IUserScopedRepository { + async findById(id: string): Promise { + return prisma.netWorthSnapshot.findUnique({ + where: {id}, + }); + } + + async findAllByUser(userId: string): Promise { + return prisma.netWorthSnapshot.findMany({ + where: {userId}, + orderBy: {date: 'desc'}, + }); + } + + async create(data: Prisma.NetWorthSnapshotCreateInput): Promise { + return prisma.netWorthSnapshot.create({ + data, + }); + } + + async update(id: string, data: Prisma.NetWorthSnapshotUpdateInput): Promise { + return prisma.netWorthSnapshot.update({ + where: {id}, + data, + }); + } + + async delete(id: string): Promise { + await prisma.netWorthSnapshot.delete({ + where: {id}, + }); + } + + /** + * Get the latest snapshot for a user + */ + async getLatest(userId: string): Promise { + return prisma.netWorthSnapshot.findFirst({ + where: {userId}, + orderBy: {date: 'desc'}, + }); + } + + /** + * Get snapshots within a date range + */ + async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise { + return prisma.netWorthSnapshot.findMany({ + where: { + userId, + date: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: {date: 'asc'}, + }); + } + + /** + * Check if a snapshot exists for a specific date + */ + async existsForDate(userId: string, date: Date): Promise { + const count = await prisma.netWorthSnapshot.count({ + where: { + userId, + date, + }, + }); + + return count > 0; + } + + /** + * Get growth over time (percentage change between snapshots) + */ + async getGrowthStats(userId: string, limit: number = 12): Promise { + const snapshots = await prisma.netWorthSnapshot.findMany({ + where: {userId}, + orderBy: {date: 'desc'}, + take: limit, + }); + + const stats = []; + for (let i = 0; i < snapshots.length - 1; i++) { + const current = snapshots[i]; + const previous = snapshots[i + 1]; + const growthAmount = current.netWorth - previous.netWorth; + const growthPercent = + previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0; + + stats.push({ + date: current.date, + netWorth: current.netWorth, + growthAmount, + growthPercent: parseFloat(growthPercent.toFixed(2)), + }); + } + + return stats; + } +} diff --git a/backend-api/src/repositories/UserRepository.ts b/backend-api/src/repositories/UserRepository.ts new file mode 100644 index 0000000..22549eb --- /dev/null +++ b/backend-api/src/repositories/UserRepository.ts @@ -0,0 +1,51 @@ +import {User, Prisma} from '@prisma/client'; +import {prisma} from '../config/database'; +import {IRepository} from './interfaces/IRepository'; + +/** + * User Repository + * Implements Single Responsibility: Only handles User data access + * Implements Dependency Inversion: Implements IRepository interface + */ +export class UserRepository implements IRepository { + async findById(id: string): Promise { + return prisma.user.findUnique({where: {id}}); + } + + async findByEmail(email: string): Promise { + return prisma.user.findUnique({where: {email}}); + } + + async findAll(): Promise { + return prisma.user.findMany({ + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + password: false, // Never return password + }, + }) as unknown as User[]; + } + + async create(data: Prisma.UserCreateInput): Promise { + return prisma.user.create({data}); + } + + async update(id: string, data: Prisma.UserUpdateInput): Promise { + return prisma.user.update({ + where: {id}, + data, + }); + } + + async delete(id: string): Promise { + await prisma.user.delete({where: {id}}); + } + + async emailExists(email: string): Promise { + const count = await prisma.user.count({where: {email}}); + return count > 0; + } +} diff --git a/backend-api/src/repositories/interfaces/IRepository.ts b/backend-api/src/repositories/interfaces/IRepository.ts new file mode 100644 index 0000000..e6adbb9 --- /dev/null +++ b/backend-api/src/repositories/interfaces/IRepository.ts @@ -0,0 +1,21 @@ +/** + * Generic Repository Interface + * Implements Interface Segregation: Base interface for common operations + * Implements Dependency Inversion: Depend on abstractions, not concretions + */ +export interface IRepository { + findById(id: string): Promise; + findAll(filters?: Record): Promise; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + delete(id: string): Promise; +} + +/** + * User-scoped repository interface + * For entities that belong to a specific user + */ +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 new file mode 100644 index 0000000..d5af2b9 --- /dev/null +++ b/backend-api/src/routes/assets.ts @@ -0,0 +1,94 @@ +import {FastifyInstance} from 'fastify'; +import {AssetController} from '../controllers/AssetController'; +import {authenticate} from '../middleware/auth'; + +/** + * Asset Routes + * All routes require authentication + */ +export async function assetRoutes(fastify: FastifyInstance) { + const controller = new AssetController(); + + // Apply authentication to all routes + fastify.addHook('preHandler', authenticate); + + fastify.get('/', { + schema: { + tags: ['Assets'], + description: 'Get all user assets', + security: [{bearerAuth: []}], + }, + handler: controller.getAll.bind(controller), + }); + + fastify.get('/:id', { + schema: { + tags: ['Assets'], + description: 'Get asset by ID', + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string', format: 'uuid'}, + }, + }, + }, + handler: controller.getById.bind(controller), + }); + + fastify.post('/', { + schema: { + tags: ['Assets'], + description: 'Create a new asset', + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name', 'type', 'value'], + properties: { + name: {type: 'string'}, + type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']}, + value: {type: 'number', minimum: 0}, + }, + }, + }, + handler: controller.create.bind(controller), + }); + + fastify.put('/:id', { + schema: { + tags: ['Assets'], + description: 'Update an asset', + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string', format: 'uuid'}, + }, + }, + body: { + type: 'object', + properties: { + name: {type: 'string'}, + type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']}, + value: {type: 'number', minimum: 0}, + }, + }, + }, + handler: controller.update.bind(controller), + }); + + fastify.delete('/:id', { + schema: { + tags: ['Assets'], + description: 'Delete an asset', + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string', format: 'uuid'}, + }, + }, + }, + handler: controller.delete.bind(controller), + }); +} diff --git a/backend-api/src/routes/auth.ts b/backend-api/src/routes/auth.ts new file mode 100644 index 0000000..7fa4a24 --- /dev/null +++ b/backend-api/src/routes/auth.ts @@ -0,0 +1,53 @@ +import {FastifyInstance} from 'fastify'; +import {AuthController} from '../controllers/AuthController'; +import {authenticate} from '../middleware/auth'; + +/** + * Authentication Routes + */ +export async function authRoutes(fastify: FastifyInstance) { + const controller = new AuthController(); + + fastify.post('/register', { + schema: { + tags: ['Authentication'], + description: 'Register a new user', + body: { + type: 'object', + required: ['email', 'password', 'name'], + properties: { + email: {type: 'string', format: 'email'}, + password: {type: 'string', minLength: 8}, + name: {type: 'string', minLength: 1}, + }, + }, + }, + handler: controller.register.bind(controller), + }); + + fastify.post('/login', { + schema: { + tags: ['Authentication'], + description: 'Login with email and password', + body: { + type: 'object', + required: ['email', 'password'], + properties: { + email: {type: 'string', format: 'email'}, + password: {type: 'string'}, + }, + }, + }, + handler: controller.login.bind(controller), + }); + + fastify.get('/profile', { + schema: { + tags: ['Authentication'], + description: 'Get current user profile', + security: [{bearerAuth: []}], + }, + preHandler: authenticate, + handler: controller.getProfile.bind(controller), + }); +} diff --git a/backend-api/src/routes/cashflow.routes.ts b/backend-api/src/routes/cashflow.routes.ts new file mode 100644 index 0000000..4fce603 --- /dev/null +++ b/backend-api/src/routes/cashflow.routes.ts @@ -0,0 +1,217 @@ +import {FastifyInstance} from 'fastify'; +import {CashflowController} from '../controllers/CashflowController'; +import {CashflowService} from '../services/CashflowService'; +import { + IncomeSourceRepository, + ExpenseRepository, + TransactionRepository, +} from '../repositories/CashflowRepository'; +import {authenticate} from '../middleware/auth'; + +const incomeRepository = new IncomeSourceRepository(); +const expenseRepository = new ExpenseRepository(); +const transactionRepository = new TransactionRepository(); +const cashflowService = new CashflowService(incomeRepository, expenseRepository, transactionRepository); +const cashflowController = new CashflowController(cashflowService); + +export async function cashflowRoutes(fastify: FastifyInstance) { + fastify.addHook('onRequest', authenticate); + + // ===== Income Source Routes ===== + + fastify.get('/income', { + schema: { + description: 'Get all income sources', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.getAllIncome.bind(cashflowController)); + + fastify.get('/income/total', { + schema: { + description: 'Get total monthly income', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.getTotalMonthlyIncome.bind(cashflowController)); + + fastify.get('/income/:id', { + schema: { + description: 'Get income source by ID', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.getOneIncome.bind(cashflowController)); + + fastify.post('/income', { + schema: { + description: 'Create income source', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name', 'amount', 'frequency'], + properties: { + name: {type: 'string'}, + amount: {type: 'number'}, + frequency: {type: 'string'}, + notes: {type: 'string'}, + }, + }, + }, + }, cashflowController.createIncome.bind(cashflowController)); + + fastify.put('/income/:id', { + schema: { + description: 'Update income source', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.updateIncome.bind(cashflowController)); + + fastify.delete('/income/:id', { + schema: { + description: 'Delete income source', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.deleteIncome.bind(cashflowController)); + + // ===== Expense Routes ===== + + fastify.get('/expenses', { + schema: { + description: 'Get all expenses', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + byCategory: {type: 'string', enum: ['true', 'false']}, + }, + }, + }, + }, cashflowController.getAllExpenses.bind(cashflowController)); + + fastify.get('/expenses/total', { + schema: { + description: 'Get total monthly expenses', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.getTotalMonthlyExpenses.bind(cashflowController)); + + fastify.get('/expenses/:id', { + schema: { + description: 'Get expense by ID', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.getOneExpense.bind(cashflowController)); + + fastify.post('/expenses', { + schema: { + description: 'Create expense', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name', 'amount', 'category', 'frequency'], + properties: { + name: {type: 'string'}, + amount: {type: 'number'}, + category: {type: 'string'}, + frequency: {type: 'string'}, + dueDate: {type: 'string', format: 'date-time'}, + notes: {type: 'string'}, + }, + }, + }, + }, cashflowController.createExpense.bind(cashflowController)); + + fastify.put('/expenses/:id', { + schema: { + description: 'Update expense', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.updateExpense.bind(cashflowController)); + + fastify.delete('/expenses/:id', { + schema: { + description: 'Delete expense', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.deleteExpense.bind(cashflowController)); + + // ===== Transaction Routes ===== + + fastify.get('/transactions', { + schema: { + description: 'Get all transactions', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + type: {type: 'string'}, + startDate: {type: 'string', format: 'date-time'}, + endDate: {type: 'string', format: 'date-time'}, + }, + }, + }, + }, cashflowController.getAllTransactions.bind(cashflowController)); + + fastify.get('/transactions/summary', { + schema: { + description: 'Get cashflow summary for date range', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + required: ['startDate', 'endDate'], + properties: { + startDate: {type: 'string', format: 'date-time'}, + endDate: {type: 'string', format: 'date-time'}, + }, + }, + }, + }, cashflowController.getCashflowSummary.bind(cashflowController)); + + fastify.get('/transactions/:id', { + schema: { + description: 'Get transaction by ID', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.getOneTransaction.bind(cashflowController)); + + fastify.post('/transactions', { + schema: { + description: 'Create transaction', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['type', 'category', 'amount', 'date'], + properties: { + type: {type: 'string'}, + category: {type: 'string'}, + amount: {type: 'number'}, + date: {type: 'string', format: 'date-time'}, + description: {type: 'string'}, + notes: {type: 'string'}, + }, + }, + }, + }, cashflowController.createTransaction.bind(cashflowController)); + + fastify.delete('/transactions/:id', { + schema: { + description: 'Delete transaction', + tags: ['Cashflow'], + security: [{bearerAuth: []}], + }, + }, cashflowController.deleteTransaction.bind(cashflowController)); +} diff --git a/backend-api/src/routes/client.routes.ts b/backend-api/src/routes/client.routes.ts new file mode 100644 index 0000000..7886de1 --- /dev/null +++ b/backend-api/src/routes/client.routes.ts @@ -0,0 +1,231 @@ +import {FastifyInstance} from 'fastify'; +import {ClientController} from '../controllers/ClientController'; +import {ClientService} from '../services/ClientService'; +import {ClientRepository} from '../repositories/ClientRepository'; +import {authenticate} from '../middleware/auth'; + +const clientRepository = new ClientRepository(); +const clientService = new ClientService(clientRepository); +const clientController = new ClientController(clientService); + +export async function clientRoutes(fastify: FastifyInstance) { + // Apply authentication to all routes + fastify.addHook('onRequest', authenticate); + + /** + * Get all clients + */ + fastify.get( + '/', + { + schema: { + description: 'Get all clients for the authenticated user', + tags: ['Clients'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + withStats: { + type: 'string', + enum: ['true', 'false'], + description: 'Include invoice statistics for each client', + }, + }, + }, + response: { + 200: { + description: 'List of clients', + type: 'object', + properties: { + clients: { + type: 'array', + items: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + email: {type: 'string'}, + phone: {type: 'string', nullable: true}, + address: {type: 'string', nullable: true}, + notes: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + clientController.getAll.bind(clientController) + ); + + /** + * Get total revenue + */ + fastify.get( + '/revenue/total', + { + schema: { + description: 'Get total revenue from all paid client invoices', + tags: ['Clients'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Total revenue', + type: 'object', + properties: { + totalRevenue: {type: 'number'}, + }, + }, + }, + }, + }, + clientController.getTotalRevenue.bind(clientController) + ); + + /** + * Get single client + */ + fastify.get( + '/:id', + { + schema: { + description: 'Get a single client by ID', + tags: ['Clients'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Client details', + type: 'object', + properties: { + client: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + email: {type: 'string'}, + phone: {type: 'string', nullable: true}, + address: {type: 'string', nullable: true}, + notes: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + clientController.getOne.bind(clientController) + ); + + /** + * Create client + */ + fastify.post( + '/', + { + schema: { + description: 'Create a new client', + tags: ['Clients'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name', 'email'], + properties: { + name: {type: 'string', minLength: 1, maxLength: 255}, + email: {type: 'string', format: 'email'}, + phone: {type: 'string', maxLength: 50}, + address: {type: 'string'}, + notes: {type: 'string'}, + }, + }, + response: { + 201: { + description: 'Client created successfully', + type: 'object', + properties: { + client: {type: 'object'}, + }, + }, + }, + }, + }, + clientController.create.bind(clientController) + ); + + /** + * Update client + */ + fastify.put( + '/:id', + { + schema: { + description: 'Update a client', + tags: ['Clients'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + body: { + type: 'object', + properties: { + name: {type: 'string', minLength: 1, maxLength: 255}, + email: {type: 'string', format: 'email'}, + phone: {type: 'string', maxLength: 50}, + address: {type: 'string'}, + notes: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Client updated successfully', + type: 'object', + properties: { + client: {type: 'object'}, + }, + }, + }, + }, + }, + clientController.update.bind(clientController) + ); + + /** + * Delete client + */ + fastify.delete( + '/:id', + { + schema: { + description: 'Delete a client', + tags: ['Clients'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 204: { + description: 'Client deleted successfully', + type: 'null', + }, + }, + }, + }, + clientController.delete.bind(clientController) + ); +} diff --git a/backend-api/src/routes/dashboard.routes.ts b/backend-api/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..2536d49 --- /dev/null +++ b/backend-api/src/routes/dashboard.routes.ts @@ -0,0 +1,106 @@ +import {FastifyInstance} from 'fastify'; +import {DashboardController} from '../controllers/DashboardController'; +import {DashboardService} from '../services/DashboardService'; +import {AssetRepository} from '../repositories/AssetRepository'; +import {LiabilityRepository} from '../repositories/LiabilityRepository'; +import {InvoiceRepository} from '../repositories/InvoiceRepository'; +import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; +import { + IncomeSourceRepository, + ExpenseRepository, + TransactionRepository, +} from '../repositories/CashflowRepository'; +import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository'; +import {authenticate} from '../middleware/auth'; + +const assetRepository = new AssetRepository(); +const liabilityRepository = new LiabilityRepository(); +const invoiceRepository = new InvoiceRepository(); +const debtAccountRepository = new DebtAccountRepository(); +const incomeRepository = new IncomeSourceRepository(); +const expenseRepository = new ExpenseRepository(); +const transactionRepository = new TransactionRepository(); +const snapshotRepository = new NetWorthSnapshotRepository(); + +const dashboardService = new DashboardService( + assetRepository, + liabilityRepository, + invoiceRepository, + debtAccountRepository, + incomeRepository, + expenseRepository, + transactionRepository, + snapshotRepository +); + +const dashboardController = new DashboardController(dashboardService); + +export async function dashboardRoutes(fastify: FastifyInstance) { + fastify.addHook('onRequest', authenticate); + + /** + * Get dashboard summary + */ + fastify.get( + '/summary', + { + schema: { + description: 'Get comprehensive financial dashboard summary', + tags: ['Dashboard'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Dashboard summary data', + type: 'object', + properties: { + netWorth: { + type: 'object', + properties: { + current: {type: 'number'}, + assets: {type: 'number'}, + liabilities: {type: 'number'}, + change: {type: 'number'}, + lastUpdated: {type: 'string'}, + }, + }, + invoices: { + type: 'object', + properties: { + total: {type: 'number'}, + paid: {type: 'number'}, + outstanding: {type: 'number'}, + overdue: {type: 'number'}, + }, + }, + debts: { + type: 'object', + properties: { + total: {type: 'number'}, + accounts: {type: 'number'}, + }, + }, + cashflow: { + type: 'object', + properties: { + monthlyIncome: {type: 'number'}, + monthlyExpenses: {type: 'number'}, + monthlyNet: {type: 'number'}, + last30Days: {type: 'object'}, + }, + }, + assets: { + type: 'object', + properties: { + total: {type: 'number'}, + count: {type: 'number'}, + allocation: {type: 'array'}, + }, + }, + }, + }, + }, + }, + }, + dashboardController.getSummary.bind(dashboardController) + ); +} diff --git a/backend-api/src/routes/debt.routes.ts b/backend-api/src/routes/debt.routes.ts new file mode 100644 index 0000000..66c283a --- /dev/null +++ b/backend-api/src/routes/debt.routes.ts @@ -0,0 +1,559 @@ +import {FastifyInstance} from 'fastify'; +import {DebtCategoryController} from '../controllers/DebtCategoryController'; +import {DebtCategoryService} from '../services/DebtCategoryService'; +import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository'; +import {DebtAccountController} from '../controllers/DebtAccountController'; +import {DebtAccountService} from '../services/DebtAccountService'; +import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; +import {DebtPaymentController} from '../controllers/DebtPaymentController'; +import {DebtPaymentService} from '../services/DebtPaymentService'; +import {DebtPaymentRepository} from '../repositories/DebtPaymentRepository'; +import {authenticate} from '../middleware/auth'; + +const categoryRepository = new DebtCategoryRepository(); +const categoryService = new DebtCategoryService(categoryRepository); +const categoryController = new DebtCategoryController(categoryService); + +const accountRepository = new DebtAccountRepository(); +const accountService = new DebtAccountService(accountRepository, categoryRepository); +const accountController = new DebtAccountController(accountService); + +const paymentRepository = new DebtPaymentRepository(); +const paymentService = new DebtPaymentService(paymentRepository, accountRepository); +const paymentController = new DebtPaymentController(paymentService); + +export async function debtRoutes(fastify: FastifyInstance) { + // Apply authentication to all routes + fastify.addHook('onRequest', authenticate); + + /** + * Get all debt categories + */ + fastify.get( + '/categories', + { + schema: { + description: 'Get all debt categories for the authenticated user', + tags: ['Debts'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + withStats: { + type: 'string', + enum: ['true', 'false'], + description: 'Include statistics for each category', + }, + }, + }, + response: { + 200: { + description: 'List of debt categories', + type: 'object', + properties: { + categories: { + type: 'array', + items: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + description: {type: 'string', nullable: true}, + color: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + categoryController.getAll.bind(categoryController) + ); + + /** + * Get single debt category + */ + fastify.get( + '/categories/:id', + { + schema: { + description: 'Get a single debt category by ID', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Debt category details', + type: 'object', + properties: { + category: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + description: {type: 'string', nullable: true}, + color: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + categoryController.getOne.bind(categoryController) + ); + + /** + * Create debt category + */ + fastify.post( + '/categories', + { + schema: { + description: 'Create a new debt category', + tags: ['Debts'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name'], + properties: { + name: {type: 'string', minLength: 1, maxLength: 255}, + description: {type: 'string'}, + color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}, + }, + }, + response: { + 201: { + description: 'Debt category created successfully', + type: 'object', + properties: { + category: {type: 'object'}, + }, + }, + }, + }, + }, + categoryController.create.bind(categoryController) + ); + + /** + * Update debt category + */ + fastify.put( + '/categories/:id', + { + schema: { + description: 'Update a debt category', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + body: { + type: 'object', + properties: { + name: {type: 'string', minLength: 1, maxLength: 255}, + description: {type: 'string'}, + color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}, + }, + }, + response: { + 200: { + description: 'Debt category updated successfully', + type: 'object', + properties: { + category: {type: 'object'}, + }, + }, + }, + }, + }, + categoryController.update.bind(categoryController) + ); + + /** + * Delete debt category + */ + fastify.delete( + '/categories/:id', + { + schema: { + description: 'Delete a debt category', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 204: { + description: 'Debt category deleted successfully', + type: 'null', + }, + }, + }, + }, + categoryController.delete.bind(categoryController) + ); + + // ===== Debt Account Routes ===== + + /** + * Get all debt accounts + */ + fastify.get( + '/accounts', + { + schema: { + description: 'Get all debt accounts for the authenticated user', + tags: ['Debts'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + withStats: {type: 'string', enum: ['true', 'false']}, + categoryId: {type: 'string', description: 'Filter by category ID'}, + }, + }, + response: { + 200: { + description: 'List of debt accounts', + type: 'object', + properties: { + accounts: {type: 'array', items: {type: 'object'}}, + }, + }, + }, + }, + }, + accountController.getAll.bind(accountController) + ); + + /** + * Get total debt + */ + fastify.get( + '/accounts/total', + { + schema: { + description: 'Get total debt across all accounts', + tags: ['Debts'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Total debt', + type: 'object', + properties: { + totalDebt: {type: 'number'}, + }, + }, + }, + }, + }, + accountController.getTotalDebt.bind(accountController) + ); + + /** + * Get single debt account + */ + fastify.get( + '/accounts/:id', + { + schema: { + description: 'Get a single debt account by ID', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Debt account details', + type: 'object', + properties: { + account: {type: 'object'}, + }, + }, + }, + }, + }, + accountController.getOne.bind(accountController) + ); + + /** + * Create debt account + */ + fastify.post( + '/accounts', + { + schema: { + description: 'Create a new debt account', + tags: ['Debts'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['categoryId', 'name', 'creditor', 'originalBalance', 'currentBalance'], + properties: { + categoryId: {type: 'string', format: 'uuid'}, + name: {type: 'string', minLength: 1, maxLength: 255}, + creditor: {type: 'string', minLength: 1, maxLength: 255}, + accountNumber: {type: 'string', maxLength: 100}, + originalBalance: {type: 'number', minimum: 0}, + currentBalance: {type: 'number', minimum: 0}, + interestRate: {type: 'number', minimum: 0, maximum: 100}, + minimumPayment: {type: 'number', minimum: 0}, + dueDate: {type: 'string', format: 'date-time'}, + notes: {type: 'string'}, + }, + }, + response: { + 201: { + description: 'Debt account created successfully', + type: 'object', + properties: { + account: {type: 'object'}, + }, + }, + }, + }, + }, + accountController.create.bind(accountController) + ); + + /** + * Update debt account + */ + fastify.put( + '/accounts/:id', + { + schema: { + description: 'Update a debt account', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + body: { + type: 'object', + properties: { + name: {type: 'string', minLength: 1, maxLength: 255}, + creditor: {type: 'string', minLength: 1, maxLength: 255}, + accountNumber: {type: 'string', maxLength: 100}, + currentBalance: {type: 'number', minimum: 0}, + interestRate: {type: 'number', minimum: 0, maximum: 100}, + minimumPayment: {type: 'number', minimum: 0}, + dueDate: {type: 'string', format: 'date-time'}, + notes: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Debt account updated successfully', + type: 'object', + properties: { + account: {type: 'object'}, + }, + }, + }, + }, + }, + accountController.update.bind(accountController) + ); + + /** + * Delete debt account + */ + fastify.delete( + '/accounts/:id', + { + schema: { + description: 'Delete a debt account', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 204: { + description: 'Debt account deleted successfully', + type: 'null', + }, + }, + }, + }, + accountController.delete.bind(accountController) + ); + + // ===== Debt Payment Routes ===== + + /** + * Get all debt payments + */ + fastify.get( + '/payments', + { + schema: { + description: 'Get all debt payments for the authenticated user', + tags: ['Debts'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + accountId: {type: 'string', description: 'Filter by account ID'}, + startDate: {type: 'string', format: 'date-time'}, + endDate: {type: 'string', format: 'date-time'}, + }, + }, + response: { + 200: { + description: 'List of debt payments', + type: 'object', + properties: { + payments: {type: 'array', items: {type: 'object'}}, + }, + }, + }, + }, + }, + paymentController.getAll.bind(paymentController) + ); + + /** + * Get total payments + */ + fastify.get( + '/payments/total', + { + schema: { + description: 'Get total payments made across all accounts', + tags: ['Debts'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Total payments', + type: 'object', + properties: { + totalPayments: {type: 'number'}, + }, + }, + }, + }, + }, + paymentController.getTotalPayments.bind(paymentController) + ); + + /** + * Get single debt payment + */ + fastify.get( + '/payments/:id', + { + schema: { + description: 'Get a single debt payment by ID', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Debt payment details', + type: 'object', + properties: { + payment: {type: 'object'}, + }, + }, + }, + }, + }, + paymentController.getOne.bind(paymentController) + ); + + /** + * Create debt payment + */ + fastify.post( + '/payments', + { + schema: { + description: 'Create a new debt payment', + tags: ['Debts'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['accountId', 'amount', 'paymentDate'], + properties: { + accountId: {type: 'string', format: 'uuid'}, + amount: {type: 'number', minimum: 0.01}, + paymentDate: {type: 'string', format: 'date-time'}, + notes: {type: 'string'}, + }, + }, + response: { + 201: { + description: 'Debt payment created successfully', + type: 'object', + properties: { + payment: {type: 'object'}, + }, + }, + }, + }, + }, + paymentController.create.bind(paymentController) + ); + + /** + * Delete debt payment + */ + fastify.delete( + '/payments/:id', + { + schema: { + description: 'Delete a debt payment', + tags: ['Debts'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 204: { + description: 'Debt payment deleted successfully', + type: 'null', + }, + }, + }, + }, + paymentController.delete.bind(paymentController) + ); +} diff --git a/backend-api/src/routes/invoice.routes.ts b/backend-api/src/routes/invoice.routes.ts new file mode 100644 index 0000000..52b6dd2 --- /dev/null +++ b/backend-api/src/routes/invoice.routes.ts @@ -0,0 +1,337 @@ +import {FastifyInstance} from 'fastify'; +import {InvoiceController} from '../controllers/InvoiceController'; +import {InvoiceService} from '../services/InvoiceService'; +import {InvoiceRepository} from '../repositories/InvoiceRepository'; +import {ClientRepository} from '../repositories/ClientRepository'; +import {authenticate} from '../middleware/auth'; + +const invoiceRepository = new InvoiceRepository(); +const clientRepository = new ClientRepository(); +const invoiceService = new InvoiceService(invoiceRepository, clientRepository); +const invoiceController = new InvoiceController(invoiceService); + +export async function invoiceRoutes(fastify: FastifyInstance) { + // Apply authentication to all routes + fastify.addHook('onRequest', authenticate); + + /** + * Get all invoices + */ + fastify.get( + '/', + { + schema: { + description: 'Get all invoices for the authenticated user', + tags: ['Invoices'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + clientId: {type: 'string', description: 'Filter by client ID'}, + status: {type: 'string', description: 'Filter by status'}, + }, + }, + response: { + 200: { + description: 'List of invoices', + type: 'object', + properties: { + invoices: { + type: 'array', + items: { + type: 'object', + properties: { + id: {type: 'string'}, + invoiceNumber: {type: 'string'}, + status: {type: 'string'}, + issueDate: {type: 'string'}, + dueDate: {type: 'string'}, + subtotal: {type: 'number'}, + tax: {type: 'number'}, + total: {type: 'number'}, + notes: {type: 'string', nullable: true}, + terms: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + invoiceController.getAll.bind(invoiceController) + ); + + /** + * Get invoice statistics + */ + fastify.get( + '/stats', + { + schema: { + description: 'Get invoice statistics (total, paid, outstanding, overdue)', + tags: ['Invoices'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Invoice statistics', + type: 'object', + properties: { + stats: { + type: 'object', + properties: { + total: {type: 'number'}, + paid: {type: 'number'}, + outstanding: {type: 'number'}, + overdue: {type: 'number'}, + }, + }, + }, + }, + }, + }, + }, + invoiceController.getStats.bind(invoiceController) + ); + + /** + * Get overdue invoices + */ + fastify.get( + '/overdue', + { + schema: { + description: 'Get all overdue invoices', + tags: ['Invoices'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'List of overdue invoices', + type: 'object', + properties: { + invoices: {type: 'array', items: {type: 'object'}}, + }, + }, + }, + }, + }, + invoiceController.getOverdue.bind(invoiceController) + ); + + /** + * Get single invoice + */ + fastify.get( + '/:id', + { + schema: { + description: 'Get a single invoice by ID', + tags: ['Invoices'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Invoice details', + type: 'object', + properties: { + invoice: { + type: 'object', + properties: { + id: {type: 'string'}, + invoiceNumber: {type: 'string'}, + status: {type: 'string'}, + issueDate: {type: 'string'}, + dueDate: {type: 'string'}, + subtotal: {type: 'number'}, + tax: {type: 'number'}, + total: {type: 'number'}, + notes: {type: 'string', nullable: true}, + terms: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + invoiceController.getOne.bind(invoiceController) + ); + + /** + * Create invoice + */ + fastify.post( + '/', + { + schema: { + description: 'Create a new invoice', + tags: ['Invoices'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['clientId', 'issueDate', 'dueDate', 'lineItems'], + properties: { + clientId: {type: 'string', format: 'uuid'}, + issueDate: {type: 'string', format: 'date-time'}, + dueDate: {type: 'string', format: 'date-time'}, + lineItems: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['description', 'quantity', 'unitPrice', 'amount'], + properties: { + description: {type: 'string', minLength: 1}, + quantity: {type: 'number', minimum: 1}, + unitPrice: {type: 'number', minimum: 0}, + amount: {type: 'number', minimum: 0}, + }, + }, + }, + notes: {type: 'string'}, + terms: {type: 'string'}, + }, + }, + response: { + 201: { + description: 'Invoice created successfully', + type: 'object', + properties: { + invoice: {type: 'object'}, + }, + }, + }, + }, + }, + invoiceController.create.bind(invoiceController) + ); + + /** + * Update invoice + */ + fastify.put( + '/:id', + { + schema: { + description: 'Update an invoice', + tags: ['Invoices'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + body: { + type: 'object', + properties: { + issueDate: {type: 'string', format: 'date-time'}, + dueDate: {type: 'string', format: 'date-time'}, + lineItems: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['description', 'quantity', 'unitPrice', 'amount'], + properties: { + description: {type: 'string', minLength: 1}, + quantity: {type: 'number', minimum: 1}, + unitPrice: {type: 'number', minimum: 0}, + amount: {type: 'number', minimum: 0}, + }, + }, + }, + notes: {type: 'string'}, + terms: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Invoice updated successfully', + type: 'object', + properties: { + invoice: {type: 'object'}, + }, + }, + }, + }, + }, + invoiceController.update.bind(invoiceController) + ); + + /** + * Update invoice status + */ + fastify.patch( + '/:id/status', + { + schema: { + description: 'Update invoice status', + tags: ['Invoices'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + body: { + type: 'object', + required: ['status'], + properties: { + status: { + type: 'string', + enum: ['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED'], + }, + }, + }, + response: { + 200: { + description: 'Invoice status updated successfully', + type: 'object', + properties: { + invoice: {type: 'object'}, + }, + }, + }, + }, + }, + invoiceController.updateStatus.bind(invoiceController) + ); + + /** + * Delete invoice + */ + fastify.delete( + '/:id', + { + schema: { + description: 'Delete an invoice', + tags: ['Invoices'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 204: { + description: 'Invoice deleted successfully', + type: 'null', + }, + }, + }, + }, + invoiceController.delete.bind(invoiceController) + ); +} diff --git a/backend-api/src/routes/liability.routes.ts b/backend-api/src/routes/liability.routes.ts new file mode 100644 index 0000000..b426cc8 --- /dev/null +++ b/backend-api/src/routes/liability.routes.ts @@ -0,0 +1,263 @@ +import {FastifyInstance} from 'fastify'; +import {LiabilityController} from '../controllers/LiabilityController'; +import {LiabilityService} from '../services/LiabilityService'; +import {LiabilityRepository} from '../repositories/LiabilityRepository'; +import {authenticate} from '../middleware/auth'; + +const liabilityRepository = new LiabilityRepository(); +const liabilityService = new LiabilityService(liabilityRepository); +const liabilityController = new LiabilityController(liabilityService); + +export async function liabilityRoutes(fastify: FastifyInstance) { + // Apply authentication to all routes + fastify.addHook('onRequest', authenticate); + + /** + * Get all liabilities + */ + fastify.get( + '/', + { + schema: { + description: 'Get all liabilities for the authenticated user', + tags: ['Liabilities'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'List of liabilities', + type: 'object', + properties: { + liabilities: { + type: 'array', + items: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + type: {type: 'string'}, + currentBalance: {type: 'number'}, + interestRate: {type: 'number', nullable: true}, + minimumPayment: {type: 'number', nullable: true}, + dueDate: {type: 'string', nullable: true}, + creditor: {type: 'string', nullable: true}, + notes: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + liabilityController.getAll.bind(liabilityController) + ); + + /** + * Get total liability value + */ + fastify.get( + '/total', + { + schema: { + description: 'Get total value of all liabilities', + tags: ['Liabilities'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Total liability value', + type: 'object', + properties: { + totalValue: {type: 'number'}, + }, + }, + }, + }, + }, + liabilityController.getTotalValue.bind(liabilityController) + ); + + /** + * Get liabilities by type + */ + fastify.get( + '/by-type', + { + schema: { + description: 'Get liabilities grouped by type', + tags: ['Liabilities'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Liabilities grouped by type', + type: 'object', + properties: { + liabilitiesByType: { + type: 'object', + additionalProperties: { + type: 'array', + items: {type: 'object'}, + }, + }, + }, + }, + }, + }, + }, + liabilityController.getByType.bind(liabilityController) + ); + + /** + * Get single liability + */ + fastify.get( + '/:id', + { + schema: { + description: 'Get a single liability by ID', + tags: ['Liabilities'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Liability details', + type: 'object', + properties: { + liability: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + type: {type: 'string'}, + currentBalance: {type: 'number'}, + interestRate: {type: 'number', nullable: true}, + minimumPayment: {type: 'number', nullable: true}, + dueDate: {type: 'string', nullable: true}, + creditor: {type: 'string', nullable: true}, + notes: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + updatedAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + liabilityController.getOne.bind(liabilityController) + ); + + /** + * Create liability + */ + fastify.post( + '/', + { + schema: { + description: 'Create a new liability', + tags: ['Liabilities'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['name', 'type', 'currentBalance'], + properties: { + name: {type: 'string', minLength: 1, maxLength: 255}, + type: {type: 'string'}, + currentBalance: {type: 'number', minimum: 0}, + interestRate: {type: 'number', minimum: 0, maximum: 100}, + minimumPayment: {type: 'number', minimum: 0}, + dueDate: {type: 'string', format: 'date-time'}, + creditor: {type: 'string', maxLength: 255}, + notes: {type: 'string'}, + }, + }, + response: { + 201: { + description: 'Liability created successfully', + type: 'object', + properties: { + liability: {type: 'object'}, + }, + }, + }, + }, + }, + liabilityController.create.bind(liabilityController) + ); + + /** + * Update liability + */ + fastify.put( + '/:id', + { + schema: { + description: 'Update a liability', + tags: ['Liabilities'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + body: { + type: 'object', + properties: { + name: {type: 'string', minLength: 1, maxLength: 255}, + type: {type: 'string'}, + currentBalance: {type: 'number', minimum: 0}, + interestRate: {type: 'number', minimum: 0, maximum: 100}, + minimumPayment: {type: 'number', minimum: 0}, + dueDate: {type: 'string', format: 'date-time'}, + creditor: {type: 'string', maxLength: 255}, + notes: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Liability updated successfully', + type: 'object', + properties: { + liability: {type: 'object'}, + }, + }, + }, + }, + }, + liabilityController.update.bind(liabilityController) + ); + + /** + * Delete liability + */ + fastify.delete( + '/:id', + { + schema: { + description: 'Delete a liability', + tags: ['Liabilities'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 204: { + description: 'Liability deleted successfully', + type: 'null', + }, + }, + }, + }, + liabilityController.delete.bind(liabilityController) + ); +} diff --git a/backend-api/src/routes/networth.routes.ts b/backend-api/src/routes/networth.routes.ts new file mode 100644 index 0000000..fad0147 --- /dev/null +++ b/backend-api/src/routes/networth.routes.ts @@ -0,0 +1,279 @@ +import {FastifyInstance} from 'fastify'; +import {NetWorthController} from '../controllers/NetWorthController'; +import {NetWorthService} from '../services/NetWorthService'; +import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository'; +import {AssetRepository} from '../repositories/AssetRepository'; +import {LiabilityRepository} from '../repositories/LiabilityRepository'; +import {authenticate} from '../middleware/auth'; + +const snapshotRepository = new NetWorthSnapshotRepository(); +const assetRepository = new AssetRepository(); +const liabilityRepository = new LiabilityRepository(); +const netWorthService = new NetWorthService(snapshotRepository, assetRepository, liabilityRepository); +const netWorthController = new NetWorthController(netWorthService); + +export async function netWorthRoutes(fastify: FastifyInstance) { + // Apply authentication to all routes + fastify.addHook('onRequest', authenticate); + + /** + * Get current net worth + */ + fastify.get( + '/current', + { + schema: { + description: 'Get current net worth (calculated or from latest snapshot)', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'Current net worth', + type: 'object', + properties: { + totalAssets: {type: 'number'}, + totalLiabilities: {type: 'number'}, + netWorth: {type: 'number'}, + asOf: {type: 'string'}, + isCalculated: {type: 'boolean'}, + }, + }, + }, + }, + }, + netWorthController.getCurrent.bind(netWorthController) + ); + + /** + * Get all snapshots + */ + fastify.get( + '/snapshots', + { + schema: { + description: 'Get all net worth snapshots', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + response: { + 200: { + description: 'List of snapshots', + type: 'object', + properties: { + snapshots: { + type: 'array', + items: { + type: 'object', + properties: { + id: {type: 'string'}, + date: {type: 'string'}, + totalAssets: {type: 'number'}, + totalLiabilities: {type: 'number'}, + netWorth: {type: 'number'}, + notes: {type: 'string', nullable: true}, + createdAt: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + netWorthController.getAllSnapshots.bind(netWorthController) + ); + + /** + * Get snapshots by date range + */ + fastify.get( + '/snapshots/range', + { + schema: { + description: 'Get snapshots within a date range', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + required: ['startDate', 'endDate'], + properties: { + startDate: {type: 'string', format: 'date-time'}, + endDate: {type: 'string', format: 'date-time'}, + }, + }, + response: { + 200: { + description: 'Snapshots in date range', + type: 'object', + properties: { + snapshots: {type: 'array', items: {type: 'object'}}, + }, + }, + }, + }, + }, + netWorthController.getByDateRange.bind(netWorthController) + ); + + /** + * Get growth statistics + */ + fastify.get( + '/growth', + { + schema: { + description: 'Get net worth growth statistics', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + querystring: { + type: 'object', + properties: { + limit: {type: 'string', description: 'Number of periods to include (default: 12)'}, + }, + }, + response: { + 200: { + description: 'Growth statistics', + type: 'object', + properties: { + stats: { + type: 'array', + items: { + type: 'object', + properties: { + date: {type: 'string'}, + netWorth: {type: 'number'}, + growthAmount: {type: 'number'}, + growthPercent: {type: 'number'}, + }, + }, + }, + }, + }, + }, + }, + }, + netWorthController.getGrowthStats.bind(netWorthController) + ); + + /** + * Get single snapshot + */ + fastify.get( + '/snapshots/:id', + { + schema: { + description: 'Get a single snapshot by ID', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 200: { + description: 'Snapshot details', + type: 'object', + properties: { + snapshot: {type: 'object'}, + }, + }, + }, + }, + }, + netWorthController.getOne.bind(netWorthController) + ); + + /** + * Create manual snapshot + */ + fastify.post( + '/snapshots', + { + schema: { + description: 'Create a new net worth snapshot manually', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + body: { + type: 'object', + required: ['date', 'totalAssets', 'totalLiabilities', 'netWorth'], + properties: { + date: {type: 'string', format: 'date-time'}, + totalAssets: {type: 'number', minimum: 0}, + totalLiabilities: {type: 'number', minimum: 0}, + netWorth: {type: 'number'}, + notes: {type: 'string'}, + }, + }, + response: { + 201: { + description: 'Snapshot created successfully', + type: 'object', + properties: { + snapshot: {type: 'object'}, + }, + }, + }, + }, + }, + netWorthController.createSnapshot.bind(netWorthController) + ); + + /** + * Create snapshot from current data + */ + fastify.post( + '/snapshots/record', + { + schema: { + description: 'Create a snapshot from current assets and liabilities', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + body: { + type: 'object', + properties: { + notes: {type: 'string'}, + }, + }, + response: { + 201: { + description: 'Snapshot created successfully', + type: 'object', + properties: { + snapshot: {type: 'object'}, + }, + }, + }, + }, + }, + netWorthController.createFromCurrent.bind(netWorthController) + ); + + /** + * Delete snapshot + */ + fastify.delete( + '/snapshots/:id', + { + schema: { + description: 'Delete a snapshot', + tags: ['Net Worth'], + security: [{bearerAuth: []}], + params: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + response: { + 204: { + description: 'Snapshot deleted successfully', + type: 'null', + }, + }, + }, + }, + netWorthController.delete.bind(netWorthController) + ); +} diff --git a/backend-api/src/server.ts b/backend-api/src/server.ts new file mode 100644 index 0000000..017e561 --- /dev/null +++ b/backend-api/src/server.ts @@ -0,0 +1,98 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import jwt from '@fastify/jwt'; +import swagger from '@fastify/swagger'; +import swaggerUi from '@fastify/swagger-ui'; +import {env} from './config/env'; +import {errorHandler} from './middleware/errorHandler'; +import {authRoutes} from './routes/auth'; +import {assetRoutes} from './routes/assets'; +import {liabilityRoutes} from './routes/liability.routes'; +import {clientRoutes} from './routes/client.routes'; +import {invoiceRoutes} from './routes/invoice.routes'; +import {netWorthRoutes} from './routes/networth.routes'; +import {debtRoutes} from './routes/debt.routes'; +import {cashflowRoutes} from './routes/cashflow.routes'; +import {dashboardRoutes} from './routes/dashboard.routes'; + +/** + * Create and configure Fastify server + * Implements Single Responsibility: Server configuration + */ +export async function buildServer() { + const fastify = Fastify({ + logger: { + level: env.NODE_ENV === 'development' ? 'info' : 'error', + transport: env.NODE_ENV === 'development' ? {target: 'pino-pretty'} : undefined, + }, + }); + + // Register plugins + await fastify.register(cors, { + origin: env.CORS_ORIGIN, + credentials: true, + }); + + await fastify.register(jwt, { + secret: env.JWT_SECRET, + sign: { + expiresIn: env.JWT_EXPIRES_IN, + }, + }); + + // Register Swagger for API documentation + await fastify.register(swagger, { + openapi: { + info: { + title: 'Personal Finances API', + description: 'API for managing personal finances including assets, liabilities, invoices, and more', + version: '1.0.0', + }, + servers: [ + { + url: `http://localhost:${env.PORT}`, + description: 'Development server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }, + }); + + await fastify.register(swaggerUi, { + routePrefix: '/docs', + uiConfig: { + docExpansion: 'list', + deepLinking: false, + }, + }); + + // Register error handler + fastify.setErrorHandler(errorHandler); + + // Health check + fastify.get('/health', async () => ({ + status: 'ok', + timestamp: new Date().toISOString(), + })); + + // Register routes + await fastify.register(authRoutes, {prefix: '/api/auth'}); + await fastify.register(assetRoutes, {prefix: '/api/assets'}); + await fastify.register(liabilityRoutes, {prefix: '/api/liabilities'}); + await fastify.register(clientRoutes, {prefix: '/api/clients'}); + await fastify.register(invoiceRoutes, {prefix: '/api/invoices'}); + await fastify.register(netWorthRoutes, {prefix: '/api/net-worth'}); + await fastify.register(debtRoutes, {prefix: '/api/debts'}); + await fastify.register(cashflowRoutes, {prefix: '/api/cashflow'}); + await fastify.register(dashboardRoutes, {prefix: '/api/dashboard'}); + + return fastify; +} diff --git a/backend-api/src/services/AssetService.ts b/backend-api/src/services/AssetService.ts new file mode 100644 index 0000000..29832d7 --- /dev/null +++ b/backend-api/src/services/AssetService.ts @@ -0,0 +1,91 @@ +import {Asset, AssetType} from '@prisma/client'; +import {AssetRepository} from '../repositories/AssetRepository'; +import {NotFoundError, ForbiddenError, ValidationError} from '../utils/errors'; + +interface CreateAssetDTO { + name: string; + type: AssetType; + value: number; +} + +interface UpdateAssetDTO { + name?: string; + type?: AssetType; + value?: number; +} + +/** + * Asset Service + * Implements Single Responsibility: Handles asset business logic + * Implements Open/Closed: Extensible for new asset-related features + */ +export class AssetService { + constructor(private assetRepository: AssetRepository) {} + + async getAll(userId: string): Promise { + return this.assetRepository.findAllByUser(userId); + } + + async getById(id: string, userId: string): Promise { + const asset = await this.assetRepository.findByIdAndUser(id, userId); + if (!asset) { + throw new NotFoundError('Asset not found'); + } + return asset; + } + + async create(userId: string, data: CreateAssetDTO): Promise { + this.validateAssetData(data); + + return this.assetRepository.create({ + name: data.name, + type: data.type, + value: data.value, + user: {connect: {id: userId}}, + }); + } + + async update(id: string, userId: string, data: UpdateAssetDTO): Promise { + const asset = await this.assetRepository.findByIdAndUser(id, userId); + if (!asset) { + throw new NotFoundError('Asset not found'); + } + + if (data.value !== undefined || data.name !== undefined || data.type !== undefined) { + this.validateAssetData({ + name: data.name || asset.name, + type: data.type || asset.type, + value: data.value !== undefined ? data.value : asset.value, + }); + } + + return this.assetRepository.update(id, data); + } + + async delete(id: string, userId: string): Promise { + const asset = await this.assetRepository.findByIdAndUser(id, userId); + if (!asset) { + throw new NotFoundError('Asset not found'); + } + + await this.assetRepository.delete(id); + } + + async getTotalValue(userId: string): Promise { + return this.assetRepository.getTotalValue(userId); + } + + private validateAssetData(data: CreateAssetDTO): void { + if (!data.name || data.name.trim().length === 0) { + throw new ValidationError('Asset name is required'); + } + + if (data.value < 0) { + throw new ValidationError('Asset value cannot be negative'); + } + + if (!Object.values(AssetType).includes(data.type)) { + throw new ValidationError('Invalid asset type'); + } + } +} diff --git a/backend-api/src/services/AuthService.ts b/backend-api/src/services/AuthService.ts new file mode 100644 index 0000000..4baeaab --- /dev/null +++ b/backend-api/src/services/AuthService.ts @@ -0,0 +1,68 @@ +import {User} from '@prisma/client'; +import {UserRepository} from '../repositories/UserRepository'; +import {PasswordService} from '../utils/password'; +import {UnauthorizedError, ValidationError, ConflictError} from '../utils/errors'; +import {DebtCategoryService} from './DebtCategoryService'; + +/** + * Authentication Service + * Implements Single Responsibility: Handles authentication logic + * Implements Dependency Inversion: Depends on UserRepository abstraction + */ +export class AuthService { + constructor( + private userRepository: UserRepository, + private debtCategoryService: DebtCategoryService + ) {} + + async register(email: string, password: string, name: string): Promise> { + // Validate password + const passwordValidation = PasswordService.validate(password); + if (!passwordValidation.valid) { + throw new ValidationError(passwordValidation.errors.join(', ')); + } + + // Check if user exists + const existingUser = await this.userRepository.findByEmail(email); + if (existingUser) { + throw new ConflictError('Email already registered'); + } + + // Hash password and create user + const hashedPassword = await PasswordService.hash(password); + const user = await this.userRepository.create({ + email, + password: hashedPassword, + name, + }); + + // Create default debt categories for new user + await this.debtCategoryService.createDefaultCategories(user.id); + + // Return user without password + const {password: _, ...userWithoutPassword} = user; + return userWithoutPassword; + } + + async login(email: string, password: string): Promise { + const user = await this.userRepository.findByEmail(email); + if (!user) { + throw new UnauthorizedError('Invalid email or password'); + } + + const passwordValid = await PasswordService.compare(password, user.password); + if (!passwordValid) { + throw new UnauthorizedError('Invalid email or password'); + } + + return user; + } + + async getUserById(id: string): Promise | null> { + const user = await this.userRepository.findById(id); + if (!user) return null; + + const {password: _, ...userWithoutPassword} = user; + return userWithoutPassword; + } +} diff --git a/backend-api/src/services/CashflowService.ts b/backend-api/src/services/CashflowService.ts new file mode 100644 index 0000000..61c3051 --- /dev/null +++ b/backend-api/src/services/CashflowService.ts @@ -0,0 +1,162 @@ +import {IncomeSource, Expense, Transaction} from '@prisma/client'; +import { + IncomeSourceRepository, + ExpenseRepository, + TransactionRepository, +} from '../repositories/CashflowRepository'; +import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; + +export interface CreateIncomeSourceDTO { + name: string; + amount: number; + frequency: string; + notes?: string; +} + +export interface CreateExpenseDTO { + name: string; + amount: number; + category: string; + frequency: string; + dueDate?: Date; + notes?: string; +} + +export interface CreateTransactionDTO { + type: string; + category: string; + amount: number; + date: Date; + description?: string; + notes?: string; +} + +/** + * Service for Cashflow business logic + */ +export class CashflowService { + constructor( + private incomeRepository: IncomeSourceRepository, + private expenseRepository: ExpenseRepository, + private transactionRepository: TransactionRepository + ) {} + + // Income Source methods + async createIncome(userId: string, data: CreateIncomeSourceDTO): Promise { + if (data.amount <= 0) throw new ValidationError('Amount must be greater than 0'); + + return this.incomeRepository.create({ + ...data, + user: {connect: {id: userId}}, + }); + } + + async getAllIncome(userId: string): Promise { + return this.incomeRepository.findAllByUser(userId); + } + + async getIncomeById(id: string, userId: string): Promise { + const income = await this.incomeRepository.findById(id); + if (!income) throw new NotFoundError('Income source not found'); + if (income.userId !== userId) throw new ForbiddenError('Access denied'); + return income; + } + + async updateIncome(id: string, userId: string, data: Partial): Promise { + await this.getIncomeById(id, userId); + if (data.amount !== undefined && data.amount <= 0) { + throw new ValidationError('Amount must be greater than 0'); + } + return this.incomeRepository.update(id, data); + } + + async deleteIncome(id: string, userId: string): Promise { + await this.getIncomeById(id, userId); + await this.incomeRepository.delete(id); + } + + async getTotalMonthlyIncome(userId: string): Promise { + return this.incomeRepository.getTotalMonthlyIncome(userId); + } + + // Expense methods + async createExpense(userId: string, data: CreateExpenseDTO): Promise { + if (data.amount <= 0) throw new ValidationError('Amount must be greater than 0'); + + return this.expenseRepository.create({ + ...data, + user: {connect: {id: userId}}, + }); + } + + async getAllExpenses(userId: string): Promise { + return this.expenseRepository.findAllByUser(userId); + } + + async getExpenseById(id: string, userId: string): Promise { + const expense = await this.expenseRepository.findById(id); + if (!expense) throw new NotFoundError('Expense not found'); + if (expense.userId !== userId) throw new ForbiddenError('Access denied'); + return expense; + } + + async updateExpense(id: string, userId: string, data: Partial): Promise { + await this.getExpenseById(id, userId); + if (data.amount !== undefined && data.amount <= 0) { + throw new ValidationError('Amount must be greater than 0'); + } + return this.expenseRepository.update(id, data); + } + + async deleteExpense(id: string, userId: string): Promise { + await this.getExpenseById(id, userId); + await this.expenseRepository.delete(id); + } + + async getTotalMonthlyExpenses(userId: string): Promise { + return this.expenseRepository.getTotalMonthlyExpenses(userId); + } + + async getExpensesByCategory(userId: string): Promise> { + return this.expenseRepository.getByCategory(userId); + } + + // Transaction methods + async createTransaction(userId: string, data: CreateTransactionDTO): Promise { + if (data.amount <= 0) throw new ValidationError('Amount must be greater than 0'); + if (data.date > new Date()) throw new ValidationError('Date cannot be in the future'); + + return this.transactionRepository.create({ + ...data, + user: {connect: {id: userId}}, + }); + } + + async getAllTransactions(userId: string): Promise { + return this.transactionRepository.findAllByUser(userId); + } + + async getTransactionById(id: string, userId: string): Promise { + const transaction = await this.transactionRepository.findById(id); + if (!transaction) throw new NotFoundError('Transaction not found'); + if (transaction.userId !== userId) throw new ForbiddenError('Access denied'); + return transaction; + } + + async deleteTransaction(id: string, userId: string): Promise { + await this.getTransactionById(id, userId); + await this.transactionRepository.delete(id); + } + + async getTransactionsByDateRange(userId: string, startDate: Date, endDate: Date): Promise { + return this.transactionRepository.getByDateRange(userId, startDate, endDate); + } + + async getTransactionsByType(userId: string, type: string): Promise { + return this.transactionRepository.getByType(userId, type); + } + + async getCashflowSummary(userId: string, startDate: Date, endDate: Date) { + return this.transactionRepository.getCashflowSummary(userId, startDate, endDate); + } +} diff --git a/backend-api/src/services/ClientService.ts b/backend-api/src/services/ClientService.ts new file mode 100644 index 0000000..760b643 --- /dev/null +++ b/backend-api/src/services/ClientService.ts @@ -0,0 +1,148 @@ +import {Client} from '@prisma/client'; +import {ClientRepository} from '../repositories/ClientRepository'; +import {NotFoundError, ValidationError, ForbiddenError, ConflictError} from '../utils/errors'; + +export interface CreateClientDTO { + name: string; + email: string; + phone?: string; + address?: string; + notes?: string; +} + +export interface UpdateClientDTO { + name?: string; + email?: string; + phone?: string; + address?: string; + notes?: string; +} + +/** + * Service for Client business logic + * Implements Single Responsibility Principle - handles only business logic + * Implements Dependency Inversion - depends on repository abstraction + */ +export class ClientService { + constructor(private clientRepository: ClientRepository) {} + + /** + * Create a new client + */ + async create(userId: string, data: CreateClientDTO): Promise { + this.validateClientData(data); + + // Check for duplicate email + const existing = await this.clientRepository.findByEmail(userId, data.email); + if (existing) { + throw new ConflictError('A client with this email already exists'); + } + + return this.clientRepository.create({ + name: data.name, + email: data.email, + phone: data.phone, + address: data.address, + notes: data.notes, + user: { + connect: {id: userId}, + }, + }); + } + + /** + * Get all clients for a user + */ + async getAllByUser(userId: string): Promise { + return this.clientRepository.findAllByUser(userId); + } + + /** + * Get clients with statistics + */ + async getWithStats(userId: string): Promise { + return this.clientRepository.getWithStats(userId); + } + + /** + * Get a single client by ID + */ + async getById(id: string, userId: string): Promise { + const client = await this.clientRepository.findById(id); + + if (!client) { + throw new NotFoundError('Client not found'); + } + + // Ensure user owns this client + if (client.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return client; + } + + /** + * Update a client + */ + async update(id: string, userId: string, data: UpdateClientDTO): Promise { + // Verify ownership + await this.getById(id, userId); + + if (data.email) { + this.validateClientData(data as CreateClientDTO); + + // Check for duplicate email (excluding current client) + const existing = await this.clientRepository.findByEmail(userId, data.email); + if (existing && existing.id !== id) { + throw new ConflictError('A client with this email already exists'); + } + } + + return this.clientRepository.update(id, data); + } + + /** + * Delete a client + */ + async delete(id: string, userId: string): Promise { + // Verify ownership + const client = await this.getById(id, userId); + + // Check if client has invoices - we still allow deletion due to cascade + // but you might want to prevent deletion if there are invoices + // Uncomment below to prevent deletion: + // if (client.invoices && client.invoices.length > 0) { + // throw new ValidationError('Cannot delete client with existing invoices'); + // } + + await this.clientRepository.delete(id); + } + + /** + * Get total revenue from all clients + */ + async getTotalRevenue(userId: string): Promise { + return this.clientRepository.getTotalRevenue(userId); + } + + /** + * Validate client data + */ + private validateClientData(data: CreateClientDTO | UpdateClientDTO): void { + if (data.email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(data.email)) { + throw new ValidationError('Invalid email format'); + } + } + + if (data.phone) { + // Basic phone validation - at least 10 digits + const phoneDigits = data.phone.replace(/\D/g, ''); + if (phoneDigits.length < 10) { + throw new ValidationError('Phone number must contain at least 10 digits'); + } + } + } +} diff --git a/backend-api/src/services/DashboardService.ts b/backend-api/src/services/DashboardService.ts new file mode 100644 index 0000000..96c4735 --- /dev/null +++ b/backend-api/src/services/DashboardService.ts @@ -0,0 +1,96 @@ +import {AssetRepository} from '../repositories/AssetRepository'; +import {LiabilityRepository} from '../repositories/LiabilityRepository'; +import {InvoiceRepository} from '../repositories/InvoiceRepository'; +import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; +import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository'; +import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository'; + +/** + * Service for Dashboard summary data + * Aggregates data from all financial modules + */ +export class DashboardService { + constructor( + private assetRepository: AssetRepository, + private liabilityRepository: LiabilityRepository, + private invoiceRepository: InvoiceRepository, + private debtAccountRepository: DebtAccountRepository, + private incomeRepository: IncomeSourceRepository, + private expenseRepository: ExpenseRepository, + private transactionRepository: TransactionRepository, + private snapshotRepository: NetWorthSnapshotRepository + ) {} + + async getSummary(userId: string) { + // Get current net worth + const totalAssets = await this.assetRepository.getTotalValue(userId); + const totalLiabilities = await this.liabilityRepository.getTotalValue(userId); + const netWorth = totalAssets - totalLiabilities; + + // Get latest snapshot for comparison + const latestSnapshot = await this.snapshotRepository.getLatest(userId); + let netWorthChange = 0; + if (latestSnapshot) { + netWorthChange = netWorth - latestSnapshot.netWorth; + } + + // Get invoice stats + const invoiceStats = await this.invoiceRepository.getStats(userId); + + // Get debt info + const totalDebt = await this.debtAccountRepository.getTotalDebt(userId); + + // Get cashflow info + const totalMonthlyIncome = await this.incomeRepository.getTotalMonthlyIncome(userId); + const totalMonthlyExpenses = await this.expenseRepository.getTotalMonthlyExpenses(userId); + const monthlyCashflow = totalMonthlyIncome - totalMonthlyExpenses; + + // Get recent transactions (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const recentCashflow = await this.transactionRepository.getCashflowSummary( + userId, + thirtyDaysAgo, + new Date() + ); + + // Get assets by type + const assetsByType = await this.assetRepository.getByType(userId); + const assetAllocation = Object.entries(assetsByType).map(([type, assets]) => ({ + type, + count: assets.length, + totalValue: assets.reduce((sum, asset) => sum + asset.currentValue, 0), + })); + + return { + netWorth: { + current: netWorth, + assets: totalAssets, + liabilities: totalLiabilities, + change: netWorthChange, + lastUpdated: new Date(), + }, + invoices: { + total: invoiceStats.totalInvoices, + paid: invoiceStats.paidInvoices, + outstanding: invoiceStats.outstandingAmount, + overdue: invoiceStats.overdueInvoices, + }, + debts: { + total: totalDebt, + accounts: (await this.debtAccountRepository.findAllByUser(userId)).length, + }, + cashflow: { + monthlyIncome: totalMonthlyIncome, + monthlyExpenses: totalMonthlyExpenses, + monthlyNet: monthlyCashflow, + last30Days: recentCashflow, + }, + assets: { + total: totalAssets, + count: (await this.assetRepository.findAllByUser(userId)).length, + allocation: assetAllocation, + }, + }; + } +} diff --git a/backend-api/src/services/DebtAccountService.ts b/backend-api/src/services/DebtAccountService.ts new file mode 100644 index 0000000..7c61b23 --- /dev/null +++ b/backend-api/src/services/DebtAccountService.ts @@ -0,0 +1,168 @@ +import {DebtAccount} from '@prisma/client'; +import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; +import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository'; +import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; + +export interface CreateDebtAccountDTO { + categoryId: string; + name: string; + creditor: string; + accountNumber?: string; + originalBalance: number; + currentBalance: number; + interestRate?: number; + minimumPayment?: number; + dueDate?: Date; + notes?: string; +} + +export interface UpdateDebtAccountDTO { + name?: string; + creditor?: string; + accountNumber?: string; + currentBalance?: number; + interestRate?: number; + minimumPayment?: number; + dueDate?: Date; + notes?: string; +} + +/** + * Service for DebtAccount business logic + * Implements Single Responsibility Principle - handles only business logic + * Implements Dependency Inversion - depends on repository abstractions + */ +export class DebtAccountService { + constructor( + private accountRepository: DebtAccountRepository, + private categoryRepository: DebtCategoryRepository + ) {} + + /** + * Create a new debt account + */ + async create(userId: string, data: CreateDebtAccountDTO): Promise { + this.validateAccountData(data); + + // Verify category ownership + const category = await this.categoryRepository.findById(data.categoryId); + if (!category) { + throw new NotFoundError('Debt category not found'); + } + if (category.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return this.accountRepository.create({ + name: data.name, + creditor: data.creditor, + accountNumber: data.accountNumber, + originalBalance: data.originalBalance, + currentBalance: data.currentBalance, + interestRate: data.interestRate, + minimumPayment: data.minimumPayment, + dueDate: data.dueDate, + notes: data.notes, + category: { + connect: {id: data.categoryId}, + }, + }); + } + + /** + * Get all debt accounts for a user + */ + async getAllByUser(userId: string): Promise { + return this.accountRepository.findAllByUser(userId); + } + + /** + * Get debt accounts with statistics + */ + async getWithStats(userId: string): Promise { + return this.accountRepository.getWithStats(userId); + } + + /** + * Get debt accounts by category + */ + async getByCategory(categoryId: string, userId: string): Promise { + // Verify category ownership + const category = await this.categoryRepository.findById(categoryId); + if (!category) { + throw new NotFoundError('Debt category not found'); + } + if (category.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return this.accountRepository.findByCategory(categoryId); + } + + /** + * Get a single debt account by ID + */ + async getById(id: string, userId: string): Promise { + const account = await this.accountRepository.findById(id); + + if (!account) { + throw new NotFoundError('Debt account not found'); + } + + // Verify ownership through category + if (account.category.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return account; + } + + /** + * Update a debt account + */ + async update(id: string, userId: string, data: UpdateDebtAccountDTO): Promise { + await this.getById(id, userId); + + if (data.currentBalance !== undefined || data.interestRate !== undefined || data.minimumPayment !== undefined) { + this.validateAccountData(data as CreateDebtAccountDTO); + } + + return this.accountRepository.update(id, data); + } + + /** + * Delete a debt account + */ + async delete(id: string, userId: string): Promise { + await this.getById(id, userId); + await this.accountRepository.delete(id); + } + + /** + * Get total debt for a user + */ + async getTotalDebt(userId: string): Promise { + return this.accountRepository.getTotalDebt(userId); + } + + /** + * Validate account data + */ + private validateAccountData(data: CreateDebtAccountDTO | UpdateDebtAccountDTO): void { + if ('originalBalance' in data && data.originalBalance < 0) { + throw new ValidationError('Original balance cannot be negative'); + } + + if (data.currentBalance !== undefined && data.currentBalance < 0) { + throw new ValidationError('Current balance cannot be negative'); + } + + if (data.interestRate !== undefined && (data.interestRate < 0 || data.interestRate > 100)) { + throw new ValidationError('Interest rate must be between 0 and 100'); + } + + if (data.minimumPayment !== undefined && data.minimumPayment < 0) { + throw new ValidationError('Minimum payment cannot be negative'); + } + } +} diff --git a/backend-api/src/services/DebtCategoryService.ts b/backend-api/src/services/DebtCategoryService.ts new file mode 100644 index 0000000..42ebc25 --- /dev/null +++ b/backend-api/src/services/DebtCategoryService.ts @@ -0,0 +1,156 @@ +import {DebtCategory} from '@prisma/client'; +import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository'; +import {NotFoundError, ValidationError, ForbiddenError, ConflictError} from '../utils/errors'; + +export interface CreateDebtCategoryDTO { + name: string; + description?: string; + color?: string; +} + +export interface UpdateDebtCategoryDTO { + name?: string; + description?: string; + color?: string; +} + +/** + * Service for DebtCategory business logic + * Implements Single Responsibility Principle - handles only business logic + * Implements Dependency Inversion - depends on repository abstraction + */ +export class DebtCategoryService { + constructor(private categoryRepository: DebtCategoryRepository) {} + + /** + * Create default debt categories for a new user + */ + async createDefaultCategories(userId: string): Promise { + const defaultCategories = [ + {name: 'Credit Cards', description: 'Credit card debts', color: '#ef4444'}, + {name: 'Student Loans', description: 'Student loan debts', color: '#3b82f6'}, + {name: 'Auto Loans', description: 'Car and vehicle loans', color: '#10b981'}, + {name: 'Mortgages', description: 'Home mortgages', color: '#f59e0b'}, + {name: 'Personal Loans', description: 'Personal loan debts', color: '#8b5cf6'}, + {name: 'Other', description: 'Other debt types', color: '#6b7280'}, + ]; + + const categories: DebtCategory[] = []; + + for (const category of defaultCategories) { + const created = await this.categoryRepository.create({ + name: category.name, + description: category.description, + color: category.color, + user: { + connect: {id: userId}, + }, + }); + categories.push(created); + } + + return categories; + } + + /** + * Create a new debt category + */ + async create(userId: string, data: CreateDebtCategoryDTO): Promise { + this.validateCategoryData(data); + + // Check for duplicate name + const existing = await this.categoryRepository.findByName(userId, data.name); + if (existing) { + throw new ConflictError('A category with this name already exists'); + } + + return this.categoryRepository.create({ + name: data.name, + description: data.description, + color: data.color, + user: { + connect: {id: userId}, + }, + }); + } + + /** + * Get all categories for a user + */ + async getAllByUser(userId: string): Promise { + return this.categoryRepository.findAllByUser(userId); + } + + /** + * Get categories with statistics + */ + async getWithStats(userId: string): Promise { + return this.categoryRepository.getWithStats(userId); + } + + /** + * Get a single category by ID + */ + async getById(id: string, userId: string): Promise { + const category = await this.categoryRepository.findById(id); + + if (!category) { + throw new NotFoundError('Debt category not found'); + } + + if (category.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return category; + } + + /** + * Update a category + */ + async update(id: string, userId: string, data: UpdateDebtCategoryDTO): Promise { + await this.getById(id, userId); + + if (data.name) { + this.validateCategoryData(data as CreateDebtCategoryDTO); + + // Check for duplicate name (excluding current category) + const existing = await this.categoryRepository.findByName(userId, data.name); + if (existing && existing.id !== id) { + throw new ConflictError('A category with this name already exists'); + } + } + + return this.categoryRepository.update(id, data); + } + + /** + * Delete a category + */ + async delete(id: string, userId: string): Promise { + const category = await this.getById(id, userId); + + // Check if category has accounts + // Note: Cascade delete will remove all accounts and payments + // You might want to prevent deletion if there are accounts + // Uncomment below to prevent deletion: + // if (category.accounts && category.accounts.length > 0) { + // throw new ValidationError('Cannot delete category with existing accounts'); + // } + + await this.categoryRepository.delete(id); + } + + /** + * Validate category data + */ + private validateCategoryData(data: CreateDebtCategoryDTO | UpdateDebtCategoryDTO): void { + if (data.color) { + // Validate hex color format + const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; + if (!hexColorRegex.test(data.color)) { + throw new ValidationError('Color must be a valid hex color (e.g., #FF5733)'); + } + } + } +} diff --git a/backend-api/src/services/DebtPaymentService.ts b/backend-api/src/services/DebtPaymentService.ts new file mode 100644 index 0000000..fde1320 --- /dev/null +++ b/backend-api/src/services/DebtPaymentService.ts @@ -0,0 +1,143 @@ +import {DebtPayment} from '@prisma/client'; +import {DebtPaymentRepository} from '../repositories/DebtPaymentRepository'; +import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; +import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; + +export interface CreateDebtPaymentDTO { + accountId: string; + amount: number; + paymentDate: Date; + notes?: string; +} + +/** + * Service for DebtPayment business logic + * Implements Single Responsibility Principle - handles only business logic + * Implements Dependency Inversion - depends on repository abstractions + */ +export class DebtPaymentService { + constructor( + private paymentRepository: DebtPaymentRepository, + private accountRepository: DebtAccountRepository + ) {} + + /** + * Create a new debt payment + */ + async create(userId: string, data: CreateDebtPaymentDTO): Promise { + this.validatePaymentData(data); + + // Verify account ownership + const account = await this.accountRepository.findById(data.accountId); + if (!account) { + throw new NotFoundError('Debt account not found'); + } + if (account.category.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + // Create payment + const payment = await this.paymentRepository.create({ + amount: data.amount, + paymentDate: data.paymentDate, + notes: data.notes, + account: { + connect: {id: data.accountId}, + }, + }); + + // Update account current balance + const newBalance = account.currentBalance - data.amount; + await this.accountRepository.update(data.accountId, { + currentBalance: Math.max(0, newBalance), // Don't allow negative balance + }); + + return payment; + } + + /** + * Get all payments for a user + */ + async getAllByUser(userId: string): Promise { + return this.paymentRepository.findAllByUser(userId); + } + + /** + * Get payments by account + */ + async getByAccount(accountId: string, userId: string): Promise { + // Verify account ownership + const account = await this.accountRepository.findById(accountId); + if (!account) { + throw new NotFoundError('Debt account not found'); + } + if (account.category.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return this.paymentRepository.findByAccount(accountId); + } + + /** + * Get payments within a date range + */ + async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise { + return this.paymentRepository.getByDateRange(userId, startDate, endDate); + } + + /** + * Get a single payment by ID + */ + async getById(id: string, userId: string): Promise { + const payment = await this.paymentRepository.findById(id); + + if (!payment) { + throw new NotFoundError('Debt payment not found'); + } + + // Verify ownership through account and category + if (payment.account.category.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return payment; + } + + /** + * Delete a payment (and restore account balance) + */ + async delete(id: string, userId: string): Promise { + const payment = await this.getById(id, userId); + + // Restore the payment amount to account balance + const account = await this.accountRepository.findById(payment.accountId); + if (account) { + const newBalance = account.currentBalance + payment.amount; + await this.accountRepository.update(payment.accountId, { + currentBalance: newBalance, + }); + } + + await this.paymentRepository.delete(id); + } + + /** + * Get total payments for a user + */ + async getTotalPayments(userId: string): Promise { + return this.paymentRepository.getTotalPaymentsByUser(userId); + } + + /** + * Validate payment data + */ + private validatePaymentData(data: CreateDebtPaymentDTO): void { + if (data.amount <= 0) { + throw new ValidationError('Payment amount must be greater than 0'); + } + + if (data.paymentDate > new Date()) { + throw new ValidationError('Payment date cannot be in the future'); + } + } +} diff --git a/backend-api/src/services/InvoiceService.ts b/backend-api/src/services/InvoiceService.ts new file mode 100644 index 0000000..0996f7f --- /dev/null +++ b/backend-api/src/services/InvoiceService.ts @@ -0,0 +1,164 @@ +import {Invoice, InvoiceStatus, Prisma} from '@prisma/client'; +import {InvoiceRepository} from '../repositories/InvoiceRepository'; +import {NotFoundError, ValidationError} from '../utils/errors'; + +interface InvoiceLineItemDTO { + description: string; + quantity: number; + unitPrice: number; +} + +interface CreateInvoiceDTO { + clientId: string; + invoiceNumber?: string; + status?: InvoiceStatus; + issueDate: Date; + dueDate: Date; + lineItems: InvoiceLineItemDTO[]; + tax?: number; + notes?: string; +} + +interface UpdateInvoiceDTO { + status?: InvoiceStatus; + dueDate?: Date; + lineItems?: InvoiceLineItemDTO[]; + notes?: string; +} + +/** + * Invoice Service + * Handles invoice business logic including calculations + */ +export class InvoiceService { + constructor(private invoiceRepository: InvoiceRepository) {} + + async getAll(userId: string, filters?: {status?: InvoiceStatus}): Promise { + return this.invoiceRepository.findAllByUser(userId, filters) as unknown as Invoice[]; + } + + async getById(id: string, userId: string): Promise { + const invoice = await this.invoiceRepository.findByIdAndUser(id, userId); + if (!invoice) { + throw new NotFoundError('Invoice not found'); + } + return invoice as unknown as Invoice; + } + + async create(userId: string, data: CreateInvoiceDTO): Promise { + this.validateInvoiceData(data); + + // Generate invoice number if not provided + const invoiceNumber = + data.invoiceNumber || (await this.invoiceRepository.generateInvoiceNumber(userId)); + + // Check if invoice number already exists + const exists = await this.invoiceRepository.invoiceNumberExists(userId, invoiceNumber); + if (exists) { + throw new ValidationError('Invoice number already exists'); + } + + // Calculate totals + const lineItems = data.lineItems.map(item => ({ + description: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + total: item.quantity * item.unitPrice, + })); + + const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0); + const tax = data.tax || 0; + const total = subtotal + tax; + + return this.invoiceRepository.create({ + invoiceNumber, + status: data.status || InvoiceStatus.DRAFT, + issueDate: data.issueDate, + dueDate: data.dueDate, + subtotal, + tax, + total, + notes: data.notes, + user: {connect: {id: userId}}, + client: {connect: {id: data.clientId}}, + lineItems: { + create: lineItems, + }, + }); + } + + async update(id: string, userId: string, data: UpdateInvoiceDTO): Promise { + const invoice = await this.getById(id, userId); + + const updateData: Prisma.InvoiceUpdateInput = {}; + + if (data.status) { + updateData.status = data.status; + } + + if (data.dueDate) { + updateData.dueDate = data.dueDate; + } + + if (data.notes !== undefined) { + updateData.notes = data.notes; + } + + // Recalculate if line items are updated + if (data.lineItems) { + const lineItems = data.lineItems.map(item => ({ + description: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + total: item.quantity * item.unitPrice, + })); + + const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0); + const total = subtotal + (invoice.tax || 0); + + updateData.subtotal = subtotal; + updateData.total = total; + updateData.lineItems = { + deleteMany: {}, + create: lineItems, + }; + } + + return this.invoiceRepository.update(id, updateData); + } + + async updateStatus(id: string, userId: string, status: InvoiceStatus): Promise { + return this.update(id, userId, {status}); + } + + async delete(id: string, userId: string): Promise { + await this.getById(id, userId); // Verify ownership + await this.invoiceRepository.delete(id); + } + + private validateInvoiceData(data: CreateInvoiceDTO): void { + if (!data.clientId) { + throw new ValidationError('Client ID is required'); + } + + if (data.dueDate < data.issueDate) { + throw new ValidationError('Due date cannot be before issue date'); + } + + if (!data.lineItems || data.lineItems.length === 0) { + throw new ValidationError('At least one line item is required'); + } + + for (const item of data.lineItems) { + if (!item.description || item.description.trim().length === 0) { + throw new ValidationError('Line item description is required'); + } + if (item.quantity <= 0) { + throw new ValidationError('Line item quantity must be positive'); + } + if (item.unitPrice < 0) { + throw new ValidationError('Line item unit price cannot be negative'); + } + } + } +} diff --git a/backend-api/src/services/LiabilityService.ts b/backend-api/src/services/LiabilityService.ts new file mode 100644 index 0000000..95859b4 --- /dev/null +++ b/backend-api/src/services/LiabilityService.ts @@ -0,0 +1,135 @@ +import {Liability} from '@prisma/client'; +import {LiabilityRepository} from '../repositories/LiabilityRepository'; +import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; + +export interface CreateLiabilityDTO { + name: string; + type: string; + currentBalance: number; + interestRate?: number; + minimumPayment?: number; + dueDate?: Date; + creditor?: string; + notes?: string; +} + +export interface UpdateLiabilityDTO { + name?: string; + type?: string; + currentBalance?: number; + interestRate?: number; + minimumPayment?: number; + dueDate?: Date; + creditor?: string; + notes?: string; +} + +/** + * Service for Liability business logic + * Implements Single Responsibility Principle - handles only business logic + * Implements Dependency Inversion - depends on repository abstraction + */ +export class LiabilityService { + constructor(private liabilityRepository: LiabilityRepository) {} + + /** + * Create a new liability + */ + async create(userId: string, data: CreateLiabilityDTO): Promise { + this.validateLiabilityData(data); + + return this.liabilityRepository.create({ + name: data.name, + type: data.type, + currentBalance: data.currentBalance, + interestRate: data.interestRate, + minimumPayment: data.minimumPayment, + dueDate: data.dueDate, + creditor: data.creditor, + notes: data.notes, + user: { + connect: {id: userId}, + }, + }); + } + + /** + * Get all liabilities for a user + */ + async getAllByUser(userId: string): Promise { + return this.liabilityRepository.findAllByUser(userId); + } + + /** + * Get a single liability by ID + */ + async getById(id: string, userId: string): Promise { + const liability = await this.liabilityRepository.findById(id); + + if (!liability) { + throw new NotFoundError('Liability not found'); + } + + // Ensure user owns this liability + if (liability.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return liability; + } + + /** + * Update a liability + */ + async update(id: string, userId: string, data: UpdateLiabilityDTO): Promise { + // Verify ownership + await this.getById(id, userId); + + if (data.currentBalance !== undefined || data.interestRate !== undefined || data.minimumPayment !== undefined) { + this.validateLiabilityData(data as CreateLiabilityDTO); + } + + return this.liabilityRepository.update(id, data); + } + + /** + * Delete a liability + */ + async delete(id: string, userId: string): Promise { + // Verify ownership + await this.getById(id, userId); + + await this.liabilityRepository.delete(id); + } + + /** + * Get total liability value for a user + */ + async getTotalValue(userId: string): Promise { + return this.liabilityRepository.getTotalValue(userId); + } + + /** + * Get liabilities grouped by type + */ + async getByType(userId: string): Promise> { + return this.liabilityRepository.getByType(userId); + } + + /** + * Validate liability data + */ + private validateLiabilityData(data: CreateLiabilityDTO | UpdateLiabilityDTO): void { + if (data.currentBalance !== undefined && data.currentBalance < 0) { + throw new ValidationError('Current balance cannot be negative'); + } + + if (data.interestRate !== undefined && (data.interestRate < 0 || data.interestRate > 100)) { + throw new ValidationError('Interest rate must be between 0 and 100'); + } + + if (data.minimumPayment !== undefined && data.minimumPayment < 0) { + throw new ValidationError('Minimum payment cannot be negative'); + } + } +} diff --git a/backend-api/src/services/NetWorthService.ts b/backend-api/src/services/NetWorthService.ts new file mode 100644 index 0000000..f45da73 --- /dev/null +++ b/backend-api/src/services/NetWorthService.ts @@ -0,0 +1,184 @@ +import {NetWorthSnapshot} from '@prisma/client'; +import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository'; +import {AssetRepository} from '../repositories/AssetRepository'; +import {LiabilityRepository} from '../repositories/LiabilityRepository'; +import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; + +export interface CreateSnapshotDTO { + date: Date; + totalAssets: number; + totalLiabilities: number; + netWorth: number; + notes?: string; +} + +/** + * Service for Net Worth business logic + * Implements Single Responsibility Principle - handles only business logic + * Implements Dependency Inversion - depends on repository abstractions + */ +export class NetWorthService { + constructor( + private snapshotRepository: NetWorthSnapshotRepository, + private assetRepository: AssetRepository, + private liabilityRepository: LiabilityRepository + ) {} + + /** + * Create a new net worth snapshot + */ + async createSnapshot(userId: string, data: CreateSnapshotDTO): Promise { + this.validateSnapshotData(data); + + // Check if snapshot already exists for this date + const exists = await this.snapshotRepository.existsForDate(userId, data.date); + if (exists) { + throw new ValidationError('A snapshot already exists for this date'); + } + + // Verify the net worth calculation + const calculatedNetWorth = data.totalAssets - data.totalLiabilities; + if (Math.abs(calculatedNetWorth - data.netWorth) > 0.01) { + // Allow small floating point differences + throw new ValidationError( + `Net worth calculation mismatch. Expected ${calculatedNetWorth}, got ${data.netWorth}` + ); + } + + return this.snapshotRepository.create({ + date: data.date, + totalAssets: data.totalAssets, + totalLiabilities: data.totalLiabilities, + netWorth: data.netWorth, + notes: data.notes, + user: { + connect: {id: userId}, + }, + }); + } + + /** + * Create a snapshot from current assets and liabilities + */ + async createFromCurrent(userId: string, notes?: string): Promise { + const totalAssets = await this.assetRepository.getTotalValue(userId); + const totalLiabilities = await this.liabilityRepository.getTotalValue(userId); + const netWorth = totalAssets - totalLiabilities; + + return this.createSnapshot(userId, { + date: new Date(), + totalAssets, + totalLiabilities, + netWorth, + notes, + }); + } + + /** + * Get all snapshots for a user + */ + async getAllSnapshots(userId: string): Promise { + return this.snapshotRepository.findAllByUser(userId); + } + + /** + * Get snapshots within a date range + */ + async getSnapshotsByDateRange( + userId: string, + startDate: Date, + endDate: Date + ): Promise { + return this.snapshotRepository.getByDateRange(userId, startDate, endDate); + } + + /** + * Get current net worth (from latest snapshot or calculate from current data) + */ + async getCurrentNetWorth(userId: string): Promise<{ + totalAssets: number; + totalLiabilities: number; + netWorth: number; + asOf: Date; + isCalculated: boolean; + }> { + const latestSnapshot = await this.snapshotRepository.getLatest(userId); + + // If we have a recent snapshot (within last 24 hours), use it + if (latestSnapshot) { + const hoursSinceSnapshot = + (Date.now() - latestSnapshot.date.getTime()) / (1000 * 60 * 60); + + if (hoursSinceSnapshot < 24) { + return { + totalAssets: latestSnapshot.totalAssets, + totalLiabilities: latestSnapshot.totalLiabilities, + netWorth: latestSnapshot.netWorth, + asOf: latestSnapshot.date, + isCalculated: false, + }; + } + } + + // Otherwise, calculate from current assets and liabilities + const totalAssets = await this.assetRepository.getTotalValue(userId); + const totalLiabilities = await this.liabilityRepository.getTotalValue(userId); + + return { + totalAssets, + totalLiabilities, + netWorth: totalAssets - totalLiabilities, + asOf: new Date(), + isCalculated: true, + }; + } + + /** + * Get a single snapshot by ID + */ + async getById(id: string, userId: string): Promise { + const snapshot = await this.snapshotRepository.findById(id); + + if (!snapshot) { + throw new NotFoundError('Snapshot not found'); + } + + if (snapshot.userId !== userId) { + throw new ForbiddenError('Access denied'); + } + + return snapshot; + } + + /** + * Delete a snapshot + */ + async delete(id: string, userId: string): Promise { + await this.getById(id, userId); + await this.snapshotRepository.delete(id); + } + + /** + * Get growth statistics + */ + async getGrowthStats(userId: string, limit?: number): Promise { + return this.snapshotRepository.getGrowthStats(userId, limit); + } + + /** + * Validate snapshot data + */ + private validateSnapshotData(data: CreateSnapshotDTO): void { + if (data.totalAssets < 0) { + throw new ValidationError('Total assets cannot be negative'); + } + + if (data.totalLiabilities < 0) { + throw new ValidationError('Total liabilities cannot be negative'); + } + + if (data.date > new Date()) { + throw new ValidationError('Snapshot date cannot be in the future'); + } + } +} diff --git a/backend-api/src/utils/errors.ts b/backend-api/src/utils/errors.ts new file mode 100644 index 0000000..426ca7d --- /dev/null +++ b/backend-api/src/utils/errors.ts @@ -0,0 +1,38 @@ +/** + * Custom error classes + * Implements Open/Closed Principle: Extensible for new error types + */ + +export abstract class AppError extends Error { + abstract statusCode: number; + + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +export class NotFoundError extends AppError { + statusCode = 404; +} + +export class ValidationError extends AppError { + statusCode = 400; +} + +export class UnauthorizedError extends AppError { + statusCode = 401; +} + +export class ForbiddenError extends AppError { + statusCode = 403; +} + +export class ConflictError extends AppError { + statusCode = 409; +} + +export class InternalServerError extends AppError { + statusCode = 500; +} diff --git a/backend-api/src/utils/password.ts b/backend-api/src/utils/password.ts new file mode 100644 index 0000000..74ab504 --- /dev/null +++ b/backend-api/src/utils/password.ts @@ -0,0 +1,39 @@ +import bcrypt from 'bcryptjs'; + +/** + * Password hashing utilities + * Implements Single Responsibility: Only handles password operations + */ +export class PasswordService { + private static readonly SALT_ROUNDS = 10; + + static async hash(password: string): Promise { + return bcrypt.hash(password, this.SALT_ROUNDS); + } + + static async compare(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } + + static validate(password: string): {valid: boolean; errors: string[]} { + const errors: string[] = []; + + if (password.length < 8) { + errors.push('Password must be at least 8 characters'); + } + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + if (!/[0-9]/.test(password)) { + errors.push('Password must contain at least one number'); + } + + return { + valid: errors.length === 0, + errors, + }; + } +} diff --git a/backend-api/tsconfig.json b/backend-api/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/backend-api/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ab36b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +version: '3.8' + +services: + # Development database + db-dev: + image: postgres:16-alpine + container_name: wealth-db-dev + environment: + POSTGRES_USER: wealth + POSTGRES_PASSWORD: wealth_dev + POSTGRES_DB: wealth_dev + ports: + - "5432:5432" + volumes: + - postgres_dev_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wealth -d wealth_dev"] + interval: 10s + timeout: 5s + retries: 5 + + # Staging database + db-staging: + image: postgres:16-alpine + container_name: wealth-db-staging + environment: + POSTGRES_USER: wealth + POSTGRES_PASSWORD: ${STAGING_DB_PASSWORD:-wealth_staging} + POSTGRES_DB: wealth_staging + ports: + - "5433:5432" + volumes: + - postgres_staging_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wealth -d wealth_staging"] + interval: 10s + timeout: 5s + retries: 5 + + # Production database + db-prod: + image: postgres:16-alpine + container_name: wealth-db-prod + environment: + POSTGRES_USER: wealth + POSTGRES_PASSWORD: ${PROD_DB_PASSWORD:?PROD_DB_PASSWORD is required} + POSTGRES_DB: wealth_prod + ports: + - "5434:5432" + volumes: + - postgres_prod_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wealth -d wealth_prod"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 1G + +volumes: + postgres_dev_data: + postgres_staging_data: + postgres_prod_data: + diff --git a/env.example b/env.example new file mode 100644 index 0000000..8d770f4 --- /dev/null +++ b/env.example @@ -0,0 +1,19 @@ +# Development (defaults work out of the box) +DEV_DATABASE_URL=postgresql://wealth:wealth_dev@localhost:5432/wealth_dev + +# Staging +STAGING_DB_PASSWORD=change-me-staging +STAGING_DATABASE_URL=postgresql://wealth:change-me-staging@localhost:5433/wealth_staging + +# Production (required - no default) +PROD_DB_PASSWORD=change-me-production +PROD_DATABASE_URL=postgresql://wealth:change-me-production@localhost:5434/wealth_prod + +# JWT Secrets +JWT_SECRET=change-me-jwt-secret-min-32-chars +JWT_REFRESH_SECRET=change-me-refresh-secret-min-32-chars + +# App +NODE_ENV=development +PORT=3000 + diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index d649067..90212b4 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -1,8 +1,11 @@ import {lazy, Suspense} from 'react'; -import {BrowserRouter, Routes, Route} from 'react-router-dom'; +import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom'; +import {useAppSelector} from '@/store'; import Layout from '@/components/Layout'; +import ProtectedRoute from '@/components/ProtectedRoute'; // Code splitting: lazy load route components +const LandingPage = lazy(() => import('@/pages/LandingPage')); const NetWorthPage = lazy(() => import('@/pages/NetWorthPage')); const CashflowPage = lazy(() => import('@/pages/CashflowPage')); const DebtsPage = lazy(() => import('@/pages/DebtsPage')); @@ -16,10 +19,27 @@ const PageLoader = () => ( ); -export default function App() { +function AppRoutes() { + const {isAuthenticated} = useAppSelector(state => state.user); + return ( - - + + {/* Public route - Landing page */} + + ) : ( + }> + + + ) + } + /> + + {/* Protected routes */} + }> }> - + + + {/* Catch-all redirect */} + } /> + + ); +} + +export default function App() { + return ( + + ); } diff --git a/frontend-web/src/components/Layout.tsx b/frontend-web/src/components/Layout.tsx index 3f7ecd7..56e5988 100644 --- a/frontend-web/src/components/Layout.tsx +++ b/frontend-web/src/components/Layout.tsx @@ -1,5 +1,6 @@ import {NavLink, Outlet} from 'react-router-dom'; -import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight} from 'lucide-react'; +import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight, LogOut} from 'lucide-react'; +import {useAppSelector, useAppDispatch, clearUser} from '@/store'; const navItems = [ {to: '/', label: 'Net Worth', icon: TrendingUp}, @@ -10,6 +11,13 @@ const navItems = [ ]; export default function Layout() { + const dispatch = useAppDispatch(); + const {currentUser} = useAppSelector(state => state.user); + + const handleLogout = () => { + dispatch(clearUser()); + }; + return (
{/* Sidebar */} @@ -35,6 +43,26 @@ export default function Layout() { ))} + + {/* User section */} +
+
+
+ {currentUser?.name?.charAt(0).toUpperCase() || '?'} +
+
+

{currentUser?.name}

+

{currentUser?.email}

+
+
+ +
{/* Main content */} diff --git a/frontend-web/src/components/ProtectedRoute.tsx b/frontend-web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..dc4a08d --- /dev/null +++ b/frontend-web/src/components/ProtectedRoute.tsx @@ -0,0 +1,13 @@ +import {Navigate, Outlet} from 'react-router-dom'; +import {useAppSelector} from '@/store'; + +export default function ProtectedRoute() { + const {isAuthenticated} = useAppSelector(state => state.user); + + if (!isAuthenticated) { + return ; + } + + return ; +} + diff --git a/frontend-web/src/components/dialogs/LoginDialog.tsx b/frontend-web/src/components/dialogs/LoginDialog.tsx new file mode 100644 index 0000000..23c89a5 --- /dev/null +++ b/frontend-web/src/components/dialogs/LoginDialog.tsx @@ -0,0 +1,88 @@ +import {useState} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {useAppDispatch, setUser} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onSwitchToSignUp: () => void; +} + +export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({ + email: '', + password: '', + }); + const [error, setError] = useState(''); + + const handleSubmit = (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], + })); + + onOpenChange(false); + setForm({email: '', password: ''}); + }; + + return ( + + + + Welcome back + Log in to your account + +
+
+
+ + setForm({...form, email: e.target.value})} + className="input-depth" + required + /> +
+
+ + setForm({...form, password: e.target.value})} + className="input-depth" + required + /> +
+ {error &&

{error}

} +
+ + + + +
+
+
+ ); +} diff --git a/frontend-web/src/components/dialogs/SignUpDialog.tsx b/frontend-web/src/components/dialogs/SignUpDialog.tsx new file mode 100644 index 0000000..c4f7fe6 --- /dev/null +++ b/frontend-web/src/components/dialogs/SignUpDialog.tsx @@ -0,0 +1,122 @@ +import {useState} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {useAppDispatch, setUser} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onSwitchToLogin: () => void; +} + +export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({ + name: '', + email: '', + password: '', + confirmPassword: '', + }); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (form.password !== form.confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (form.password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + // Mock sign up - in production this would call an API + dispatch(setUser({ + id: crypto.randomUUID(), + email: form.email, + name: form.name, + })); + + onOpenChange(false); + setForm({name: '', email: '', password: '', confirmPassword: ''}); + }; + + return ( + + + + Create an account + Enter your details to get started + +
+
+
+ + setForm({...form, name: e.target.value})} + className="input-depth" + required + /> +
+
+ + setForm({...form, email: e.target.value})} + className="input-depth" + required + /> +
+
+ + setForm({...form, password: e.target.value})} + className="input-depth" + required + /> +
+
+ + setForm({...form, confirmPassword: e.target.value})} + className="input-depth" + required + /> +
+ {error &&

{error}

} +

+ By signing up, you agree to our Terms of Service and Privacy Policy. +

+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/pages/LandingPage.tsx b/frontend-web/src/pages/LandingPage.tsx new file mode 100644 index 0000000..0dcd104 --- /dev/null +++ b/frontend-web/src/pages/LandingPage.tsx @@ -0,0 +1,144 @@ +import {useState} from 'react'; +import {Button} from '@/components/ui/button'; +import {Card, CardContent} from '@/components/ui/card'; +import {TrendingUp, CreditCard, FileText, ArrowLeftRight, Shield, BarChart3} from 'lucide-react'; +import LoginDialog from '@/components/dialogs/LoginDialog'; +import SignUpDialog from '@/components/dialogs/SignUpDialog'; + +const features = [ + { + icon: TrendingUp, + title: 'Net Worth Tracking', + description: 'Monitor your assets and liabilities over time with beautiful charts and insights.', + }, + { + icon: CreditCard, + title: 'Debt Management', + description: 'Organize and track debt paydown across multiple accounts and categories.', + }, + { + icon: ArrowLeftRight, + title: 'Cashflow Analysis', + description: 'Understand your income and expenses to optimize your savings rate.', + }, + { + icon: FileText, + title: 'Invoicing', + description: 'Create professional invoices and track payments from your clients.', + }, + { + icon: BarChart3, + title: 'Visual Reports', + description: 'Clean, minimal dashboards that put your data front and center.', + }, + { + icon: Shield, + title: 'Private & Secure', + description: 'Your financial data stays on your device. No cloud sync required.', + }, +]; + +export default function LandingPage() { + const [loginOpen, setLoginOpen] = useState(false); + const [signUpOpen, setSignUpOpen] = useState(false); + + return ( +
+ {/* Header */} +
+
+
+ Wealth +
+
+ + +
+
+
+ + {/* Hero */} +
+
+

+ Take control of your finances +

+

+ A clean, minimal tool to track your net worth, manage debt, monitor cashflow, and invoice clients—all in one place. +

+
+ + +
+
+
+ + {/* Features */} +
+
+

Everything you need

+
+ {features.map((feature) => ( + + + +

{feature.title}

+

{feature.description}

+
+
+ ))} +
+
+
+ + {/* CTA */} +
+
+

Ready to build wealth?

+

+ Start tracking your finances today. It's free to get started. +

+ +
+
+ + {/* Disclaimer */} +
+
+

+ Disclaimer: This application is for informational and personal tracking purposes only. + It does not constitute financial, investment, tax, or legal advice. The information provided should not + be relied upon for making financial decisions. Always consult with qualified professionals before making + any financial decisions. We make no guarantees about the accuracy or completeness of the data you enter + or the calculations performed. Use at your own risk. Past performance is not indicative of future results. +

+
+
+ + {/* Footer */} +
+
+ © {new Date().getFullYear()} Wealth +
+ Privacy + Terms +
+
+
+ + { setLoginOpen(false); setSignUpOpen(true); }} /> + { setSignUpOpen(false); setLoginOpen(true); }} /> +
+ ); +} +