Add backend API for personal finance management application
- Introduced a comprehensive backend API using TypeScript, Fastify, and PostgreSQL. - Added essential files including architecture documentation, environment configuration, and Docker setup. - Implemented RESTful routes for managing assets, liabilities, clients, invoices, and cashflow. - Established a robust database schema with Prisma for data management. - Integrated middleware for authentication and error handling. - Created service and repository layers to adhere to SOLID principles and clean architecture. - Included example environment variables for development, staging, and production setups.
This commit is contained in:
810
BACKEND_PROMPT.md
Normal file
810
BACKEND_PROMPT.md
Normal file
@@ -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<string, string[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../CLAUDE.md
|
||||||
6
backend-api/.env.example
Normal file
6
backend-api/.env.example
Normal file
@@ -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
|
||||||
34
backend-api/.gitignore
vendored
Normal file
34
backend-api/.gitignore
vendored
Normal file
@@ -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
|
||||||
366
backend-api/ARCHITECTURE.md
Normal file
366
backend-api/ARCHITECTURE.md
Normal file
@@ -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<Asset>
|
||||||
|
async update(id: string, userId: string, data: UpdateAssetDTO): Promise<Asset>
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetRepository - ONLY handles database operations
|
||||||
|
export class AssetRepository {
|
||||||
|
async findById(id: string): Promise<Asset | null>
|
||||||
|
async create(data: Prisma.AssetCreateInput): Promise<Asset>
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<T>` 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<T> {
|
||||||
|
findById(id: string): Promise<T | null>;
|
||||||
|
create(data: Partial<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specialized interface extends base without breaking it
|
||||||
|
export interface IUserScopedRepository<T> extends Omit<IRepository<T>, 'findAll'> {
|
||||||
|
findAllByUser(userId: string): Promise<T[]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Interface Segregation Principle (ISP)
|
||||||
|
Clients depend only on interfaces they use:
|
||||||
|
|
||||||
|
- `IRepository<T>` - Base CRUD operations
|
||||||
|
- `IUserScopedRepository<T>` - 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<User>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Invoice> {
|
||||||
|
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<Asset> {
|
||||||
|
async findAllByUser(userId: string): Promise<Asset[]> {
|
||||||
|
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
|
||||||
111
backend-api/CLAUDE.md
Normal file
111
backend-api/CLAUDE.md
Normal file
@@ -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 <file>` instead of `node <file>` or `ts-node <file>`
|
||||||
|
- Use `bun test` instead of `jest` or `vitest`
|
||||||
|
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||||
|
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||||
|
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||||
|
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||||
|
- Bun automatically loads .env, so don't use dotenv.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||||
|
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||||
|
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||||
|
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||||
|
- `WebSocket` is built-in. Don't use `ws`.
|
||||||
|
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||||
|
- Bun.$`ls` instead of execa.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use `bun test` to run tests.
|
||||||
|
|
||||||
|
```ts#index.test.ts
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
|
||||||
|
test("hello world", () => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
```ts#index.ts
|
||||||
|
import index from "./index.html"
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
routes: {
|
||||||
|
"/": index,
|
||||||
|
"/api/users/:id": {
|
||||||
|
GET: (req) => {
|
||||||
|
return new Response(JSON.stringify({ id: req.params.id }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// optional websocket support
|
||||||
|
websocket: {
|
||||||
|
open: (ws) => {
|
||||||
|
ws.send("Hello, world!");
|
||||||
|
},
|
||||||
|
message: (ws, message) => {
|
||||||
|
ws.send(message);
|
||||||
|
},
|
||||||
|
close: (ws) => {
|
||||||
|
// handle close
|
||||||
|
}
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||||
|
|
||||||
|
```html#index.html
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
<script type="module" src="./frontend.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <h1>Hello, world!</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.render(<Frontend />);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run index.ts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun --hot ./index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||||
15
backend-api/README.md
Normal file
15
backend-api/README.md
Normal file
@@ -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.
|
||||||
308
backend-api/bun.lock
Normal file
308
backend-api/bun.lock
Normal file
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backend-api/index.ts
Normal file
1
backend-api/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
console.log("Hello via Bun!");
|
||||||
34
backend-api/package.json
Normal file
34
backend-api/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
251
backend-api/prisma/schema.prisma
Normal file
251
backend-api/prisma/schema.prisma
Normal file
@@ -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")
|
||||||
|
}
|
||||||
29
backend-api/src/config/database.ts
Normal file
29
backend-api/src/config/database.ts
Normal file
@@ -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<void> {
|
||||||
|
if (DatabaseConnection.instance) {
|
||||||
|
await DatabaseConnection.instance.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = DatabaseConnection.getInstance();
|
||||||
|
export {DatabaseConnection};
|
||||||
30
backend-api/src/config/env.ts
Normal file
30
backend-api/src/config/env.ts
Normal file
@@ -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<typeof envSchema>;
|
||||||
|
|
||||||
|
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();
|
||||||
62
backend-api/src/controllers/AssetController.ts
Normal file
62
backend-api/src/controllers/AssetController.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend-api/src/controllers/AuthController.ts
Normal file
72
backend-api/src/controllers/AuthController.ts
Normal file
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
192
backend-api/src/controllers/CashflowController.ts
Normal file
192
backend-api/src/controllers/CashflowController.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
backend-api/src/controllers/ClientController.ts
Normal file
103
backend-api/src/controllers/ClientController.ts
Normal file
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend-api/src/controllers/DashboardController.ts
Normal file
17
backend-api/src/controllers/DashboardController.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
backend-api/src/controllers/DebtAccountController.ts
Normal file
116
backend-api/src/controllers/DebtAccountController.ts
Normal file
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend-api/src/controllers/DebtCategoryController.ts
Normal file
89
backend-api/src/controllers/DebtCategoryController.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend-api/src/controllers/DebtPaymentController.ts
Normal file
94
backend-api/src/controllers/DebtPaymentController.ts
Normal file
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
137
backend-api/src/controllers/InvoiceController.ts
Normal file
137
backend-api/src/controllers/InvoiceController.ts
Normal file
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
113
backend-api/src/controllers/LiabilityController.ts
Normal file
113
backend-api/src/controllers/LiabilityController.ts
Normal file
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
129
backend-api/src/controllers/NetWorthController.ts
Normal file
129
backend-api/src/controllers/NetWorthController.ts
Normal file
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend-api/src/index.ts
Normal file
37
backend-api/src/index.ts
Normal file
@@ -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();
|
||||||
36
backend-api/src/middleware/auth.ts
Normal file
36
backend-api/src/middleware/auth.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
64
backend-api/src/middleware/errorHandler.ts
Normal file
64
backend-api/src/middleware/errorHandler.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
49
backend-api/src/repositories/AssetRepository.ts
Normal file
49
backend-api/src/repositories/AssetRepository.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {Asset, Prisma} from '@prisma/client';
|
||||||
|
import {prisma} from '../config/database';
|
||||||
|
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset Repository
|
||||||
|
* Implements Single Responsibility: Only handles Asset data access
|
||||||
|
*/
|
||||||
|
export class AssetRepository implements IUserScopedRepository<Asset> {
|
||||||
|
async findById(id: string): Promise<Asset | null> {
|
||||||
|
return prisma.asset.findUnique({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdAndUser(id: string, userId: string): Promise<Asset | null> {
|
||||||
|
return prisma.asset.findFirst({
|
||||||
|
where: {id, userId},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string, filters?: Record<string, any>): Promise<Asset[]> {
|
||||||
|
return prisma.asset.findMany({
|
||||||
|
where: {userId, ...filters},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.AssetCreateInput): Promise<Asset> {
|
||||||
|
return prisma.asset.create({data});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.AssetUpdateInput): Promise<Asset> {
|
||||||
|
return prisma.asset.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.asset.delete({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalValue(userId: string): Promise<number> {
|
||||||
|
const result = await prisma.asset.aggregate({
|
||||||
|
where: {userId},
|
||||||
|
_sum: {value: true},
|
||||||
|
});
|
||||||
|
return result._sum.value || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
backend-api/src/repositories/CashflowRepository.ts
Normal file
149
backend-api/src/repositories/CashflowRepository.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {IncomeSource, Expense, Transaction, Prisma} from '@prisma/client';
|
||||||
|
import {DatabaseConnection} from '../config/database';
|
||||||
|
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
const prisma = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for IncomeSource data access
|
||||||
|
*/
|
||||||
|
export class IncomeSourceRepository implements IUserScopedRepository<IncomeSource> {
|
||||||
|
async findById(id: string): Promise<IncomeSource | null> {
|
||||||
|
return prisma.incomeSource.findUnique({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<IncomeSource[]> {
|
||||||
|
return prisma.incomeSource.findMany({
|
||||||
|
where: {userId},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.IncomeSourceCreateInput): Promise<IncomeSource> {
|
||||||
|
return prisma.incomeSource.create({data});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.IncomeSourceUpdateInput): Promise<IncomeSource> {
|
||||||
|
return prisma.incomeSource.update({where: {id}, data});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.incomeSource.delete({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalMonthlyIncome(userId: string): Promise<number> {
|
||||||
|
const result = await prisma.incomeSource.aggregate({
|
||||||
|
where: {userId},
|
||||||
|
_sum: {amount: true},
|
||||||
|
});
|
||||||
|
return result._sum.amount || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for Expense data access
|
||||||
|
*/
|
||||||
|
export class ExpenseRepository implements IUserScopedRepository<Expense> {
|
||||||
|
async findById(id: string): Promise<Expense | null> {
|
||||||
|
return prisma.expense.findUnique({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<Expense[]> {
|
||||||
|
return prisma.expense.findMany({
|
||||||
|
where: {userId},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.ExpenseCreateInput): Promise<Expense> {
|
||||||
|
return prisma.expense.create({data});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.ExpenseUpdateInput): Promise<Expense> {
|
||||||
|
return prisma.expense.update({where: {id}, data});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.expense.delete({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalMonthlyExpenses(userId: string): Promise<number> {
|
||||||
|
const result = await prisma.expense.aggregate({
|
||||||
|
where: {userId},
|
||||||
|
_sum: {amount: true},
|
||||||
|
});
|
||||||
|
return result._sum.amount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByCategory(userId: string): Promise<Record<string, Expense[]>> {
|
||||||
|
const expenses = await this.findAllByUser(userId);
|
||||||
|
return expenses.reduce((acc, expense) => {
|
||||||
|
if (!acc[expense.category]) acc[expense.category] = [];
|
||||||
|
acc[expense.category].push(expense);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Expense[]>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for Transaction data access
|
||||||
|
*/
|
||||||
|
export class TransactionRepository implements IUserScopedRepository<Transaction> {
|
||||||
|
async findById(id: string): Promise<Transaction | null> {
|
||||||
|
return prisma.transaction.findUnique({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
where: {userId},
|
||||||
|
orderBy: {date: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.TransactionCreateInput): Promise<Transaction> {
|
||||||
|
return prisma.transaction.create({data});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.transaction.delete({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
date: {gte: startDate, lte: endDate},
|
||||||
|
},
|
||||||
|
orderBy: {date: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByType(userId: string, type: string): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
where: {userId, type},
|
||||||
|
orderBy: {date: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCashflowSummary(userId: string, startDate: Date, endDate: Date): Promise<{
|
||||||
|
totalIncome: number;
|
||||||
|
totalExpenses: number;
|
||||||
|
netCashflow: number;
|
||||||
|
}> {
|
||||||
|
const transactions = await this.getByDateRange(userId, startDate, endDate);
|
||||||
|
|
||||||
|
const totalIncome = transactions
|
||||||
|
.filter(t => t.type === 'INCOME')
|
||||||
|
.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
|
const totalExpenses = transactions
|
||||||
|
.filter(t => t.type === 'EXPENSE')
|
||||||
|
.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalIncome,
|
||||||
|
totalExpenses,
|
||||||
|
netCashflow: totalIncome - totalExpenses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
121
backend-api/src/repositories/ClientRepository.ts
Normal file
121
backend-api/src/repositories/ClientRepository.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {Client, Prisma} from '@prisma/client';
|
||||||
|
import {DatabaseConnection} from '../config/database';
|
||||||
|
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
const prisma = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for Client data access
|
||||||
|
* Implements Single Responsibility Principle - handles only database operations
|
||||||
|
*/
|
||||||
|
export class ClientRepository implements IUserScopedRepository<Client> {
|
||||||
|
async findById(id: string): Promise<Client | null> {
|
||||||
|
return prisma.client.findUnique({
|
||||||
|
where: {id},
|
||||||
|
include: {
|
||||||
|
invoices: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<Client[]> {
|
||||||
|
return prisma.client.findMany({
|
||||||
|
where: {userId},
|
||||||
|
include: {
|
||||||
|
invoices: {
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.ClientCreateInput): Promise<Client> {
|
||||||
|
return prisma.client.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
invoices: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.ClientUpdateInput): Promise<Client> {
|
||||||
|
return prisma.client.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
invoices: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.client.delete({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find client by email
|
||||||
|
*/
|
||||||
|
async findByEmail(userId: string, email: string): Promise<Client | null> {
|
||||||
|
return prisma.client.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total revenue from all clients
|
||||||
|
*/
|
||||||
|
async getTotalRevenue(userId: string): Promise<number> {
|
||||||
|
const result = await prisma.invoice.aggregate({
|
||||||
|
where: {
|
||||||
|
client: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
status: 'PAID',
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
total: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result._sum.total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clients with their invoice statistics
|
||||||
|
*/
|
||||||
|
async getWithStats(userId: string): Promise<any[]> {
|
||||||
|
const clients = await prisma.client.findMany({
|
||||||
|
where: {userId},
|
||||||
|
include: {
|
||||||
|
invoices: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
total: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
|
||||||
|
return clients.map(client => ({
|
||||||
|
...client,
|
||||||
|
stats: {
|
||||||
|
totalInvoices: client.invoices.length,
|
||||||
|
paidInvoices: client.invoices.filter(inv => inv.status === 'PAID').length,
|
||||||
|
totalRevenue: client.invoices
|
||||||
|
.filter(inv => inv.status === 'PAID')
|
||||||
|
.reduce((sum, inv) => sum + inv.total, 0),
|
||||||
|
outstandingAmount: client.invoices
|
||||||
|
.filter(inv => inv.status !== 'PAID')
|
||||||
|
.reduce((sum, inv) => sum + inv.total, 0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
118
backend-api/src/repositories/DebtAccountRepository.ts
Normal file
118
backend-api/src/repositories/DebtAccountRepository.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import {DebtAccount, Prisma} from '@prisma/client';
|
||||||
|
import {DatabaseConnection} from '../config/database';
|
||||||
|
|
||||||
|
const prisma = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for DebtAccount data access
|
||||||
|
* Implements Single Responsibility Principle - handles only database operations
|
||||||
|
*/
|
||||||
|
export class DebtAccountRepository {
|
||||||
|
async findById(id: string): Promise<DebtAccount | null> {
|
||||||
|
return prisma.debtAccount.findUnique({
|
||||||
|
where: {id},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
payments: {
|
||||||
|
orderBy: {paymentDate: 'desc'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<DebtAccount[]> {
|
||||||
|
return prisma.debtAccount.findMany({
|
||||||
|
where: {
|
||||||
|
category: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
payments: {
|
||||||
|
orderBy: {paymentDate: 'desc'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCategory(categoryId: string): Promise<DebtAccount[]> {
|
||||||
|
return prisma.debtAccount.findMany({
|
||||||
|
where: {categoryId},
|
||||||
|
include: {
|
||||||
|
payments: {
|
||||||
|
orderBy: {paymentDate: 'desc'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.DebtAccountCreateInput): Promise<DebtAccount> {
|
||||||
|
return prisma.debtAccount.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
payments: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.DebtAccountUpdateInput): Promise<DebtAccount> {
|
||||||
|
return prisma.debtAccount.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
payments: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.debtAccount.delete({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total debt across all accounts for a user
|
||||||
|
*/
|
||||||
|
async getTotalDebt(userId: string): Promise<number> {
|
||||||
|
const result = await prisma.debtAccount.aggregate({
|
||||||
|
where: {
|
||||||
|
category: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
currentBalance: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result._sum.currentBalance || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get accounts with payment statistics
|
||||||
|
*/
|
||||||
|
async getWithStats(userId: string): Promise<any[]> {
|
||||||
|
const accounts = await this.findAllByUser(userId);
|
||||||
|
|
||||||
|
return accounts.map(account => {
|
||||||
|
const totalPaid = account.payments.reduce((sum, payment) => sum + payment.amount, 0);
|
||||||
|
const lastPayment = account.payments[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
stats: {
|
||||||
|
totalPaid,
|
||||||
|
numberOfPayments: account.payments.length,
|
||||||
|
lastPaymentDate: lastPayment?.paymentDate || null,
|
||||||
|
lastPaymentAmount: lastPayment?.amount || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
117
backend-api/src/repositories/DebtCategoryRepository.ts
Normal file
117
backend-api/src/repositories/DebtCategoryRepository.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import {DebtCategory, Prisma} from '@prisma/client';
|
||||||
|
import {DatabaseConnection} from '../config/database';
|
||||||
|
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
const prisma = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for DebtCategory data access
|
||||||
|
* Implements Single Responsibility Principle - handles only database operations
|
||||||
|
*/
|
||||||
|
export class DebtCategoryRepository implements IUserScopedRepository<DebtCategory> {
|
||||||
|
async findById(id: string): Promise<DebtCategory | null> {
|
||||||
|
return prisma.debtCategory.findUnique({
|
||||||
|
where: {id},
|
||||||
|
include: {
|
||||||
|
accounts: {
|
||||||
|
include: {
|
||||||
|
payments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<DebtCategory[]> {
|
||||||
|
return prisma.debtCategory.findMany({
|
||||||
|
where: {userId},
|
||||||
|
include: {
|
||||||
|
accounts: {
|
||||||
|
include: {
|
||||||
|
payments: true,
|
||||||
|
},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.DebtCategoryCreateInput): Promise<DebtCategory> {
|
||||||
|
return prisma.debtCategory.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
accounts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.DebtCategoryUpdateInput): Promise<DebtCategory> {
|
||||||
|
return prisma.debtCategory.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
accounts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.debtCategory.delete({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find category by name
|
||||||
|
*/
|
||||||
|
async findByName(userId: string, name: string): Promise<DebtCategory | null> {
|
||||||
|
return prisma.debtCategory.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total debt across all accounts in a category
|
||||||
|
*/
|
||||||
|
async getTotalDebt(categoryId: string): Promise<number> {
|
||||||
|
const result = await prisma.debtAccount.aggregate({
|
||||||
|
where: {categoryId},
|
||||||
|
_sum: {
|
||||||
|
currentBalance: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result._sum.currentBalance || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories with debt statistics
|
||||||
|
*/
|
||||||
|
async getWithStats(userId: string): Promise<any[]> {
|
||||||
|
const categories = await this.findAllByUser(userId);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
categories.map(async category => {
|
||||||
|
const totalDebt = await this.getTotalDebt(category.id);
|
||||||
|
const totalPayments = category.accounts.reduce(
|
||||||
|
(sum, account) =>
|
||||||
|
sum + account.payments.reduce((pSum, payment) => pSum + payment.amount, 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
stats: {
|
||||||
|
totalAccounts: category.accounts.length,
|
||||||
|
totalDebt,
|
||||||
|
totalPayments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
backend-api/src/repositories/DebtPaymentRepository.ts
Normal file
130
backend-api/src/repositories/DebtPaymentRepository.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import {DebtPayment, Prisma} from '@prisma/client';
|
||||||
|
import {DatabaseConnection} from '../config/database';
|
||||||
|
|
||||||
|
const prisma = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for DebtPayment data access
|
||||||
|
* Implements Single Responsibility Principle - handles only database operations
|
||||||
|
*/
|
||||||
|
export class DebtPaymentRepository {
|
||||||
|
async findById(id: string): Promise<DebtPayment | null> {
|
||||||
|
return prisma.debtPayment.findUnique({
|
||||||
|
where: {id},
|
||||||
|
include: {
|
||||||
|
account: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccount(accountId: string): Promise<DebtPayment[]> {
|
||||||
|
return prisma.debtPayment.findMany({
|
||||||
|
where: {accountId},
|
||||||
|
orderBy: {paymentDate: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<DebtPayment[]> {
|
||||||
|
return prisma.debtPayment.findMany({
|
||||||
|
where: {
|
||||||
|
account: {
|
||||||
|
category: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
account: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {paymentDate: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.DebtPaymentCreateInput): Promise<DebtPayment> {
|
||||||
|
return prisma.debtPayment.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
account: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.debtPayment.delete({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total payments for an account
|
||||||
|
*/
|
||||||
|
async getTotalPayments(accountId: string): Promise<number> {
|
||||||
|
const result = await prisma.debtPayment.aggregate({
|
||||||
|
where: {accountId},
|
||||||
|
_sum: {
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result._sum.amount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total payments for a user
|
||||||
|
*/
|
||||||
|
async getTotalPaymentsByUser(userId: string): Promise<number> {
|
||||||
|
const result = await prisma.debtPayment.aggregate({
|
||||||
|
where: {
|
||||||
|
account: {
|
||||||
|
category: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result._sum.amount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get payments within a date range
|
||||||
|
*/
|
||||||
|
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<DebtPayment[]> {
|
||||||
|
return prisma.debtPayment.findMany({
|
||||||
|
where: {
|
||||||
|
account: {
|
||||||
|
category: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paymentDate: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
account: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {paymentDate: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
76
backend-api/src/repositories/InvoiceRepository.ts
Normal file
76
backend-api/src/repositories/InvoiceRepository.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import {Invoice, Prisma, InvoiceStatus} from '@prisma/client';
|
||||||
|
import {prisma} from '../config/database';
|
||||||
|
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
type InvoiceWithLineItems = Prisma.InvoiceGetPayload<{
|
||||||
|
include: {lineItems: true; client: true};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice Repository
|
||||||
|
* Handles Invoice data access with relationships
|
||||||
|
*/
|
||||||
|
export class InvoiceRepository implements IUserScopedRepository<Invoice> {
|
||||||
|
async findById(id: string): Promise<Invoice | null> {
|
||||||
|
return prisma.invoice.findUnique({
|
||||||
|
where: {id},
|
||||||
|
include: {lineItems: true, client: true},
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdAndUser(id: string, userId: string): Promise<InvoiceWithLineItems | null> {
|
||||||
|
return prisma.invoice.findFirst({
|
||||||
|
where: {id, userId},
|
||||||
|
include: {lineItems: true, client: true},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string, filters?: {status?: InvoiceStatus}): Promise<InvoiceWithLineItems[]> {
|
||||||
|
return prisma.invoice.findMany({
|
||||||
|
where: {userId, ...filters},
|
||||||
|
include: {lineItems: true, client: true},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.InvoiceCreateInput): Promise<Invoice> {
|
||||||
|
return prisma.invoice.create({
|
||||||
|
data,
|
||||||
|
include: {lineItems: true, client: true},
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.InvoiceUpdateInput): Promise<Invoice> {
|
||||||
|
return prisma.invoice.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
include: {lineItems: true, client: true},
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.invoice.delete({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoiceNumberExists(userId: string, invoiceNumber: string, excludeId?: string): Promise<boolean> {
|
||||||
|
const count = await prisma.invoice.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
invoiceNumber,
|
||||||
|
...(excludeId && {id: {not: excludeId}}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateInvoiceNumber(userId: string): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const count = await prisma.invoice.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
invoiceNumber: {startsWith: `INV-${year}-`},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return `INV-${year}-${String(count + 1).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend-api/src/repositories/LiabilityRepository.ts
Normal file
73
backend-api/src/repositories/LiabilityRepository.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {Liability, Prisma} from '@prisma/client';
|
||||||
|
import {DatabaseConnection} from '../config/database';
|
||||||
|
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
const prisma = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for Liability data access
|
||||||
|
* Implements Single Responsibility Principle - handles only database operations
|
||||||
|
*/
|
||||||
|
export class LiabilityRepository implements IUserScopedRepository<Liability> {
|
||||||
|
async findById(id: string): Promise<Liability | null> {
|
||||||
|
return prisma.liability.findUnique({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<Liability[]> {
|
||||||
|
return prisma.liability.findMany({
|
||||||
|
where: {userId},
|
||||||
|
orderBy: {createdAt: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.LiabilityCreateInput): Promise<Liability> {
|
||||||
|
return prisma.liability.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.LiabilityUpdateInput): Promise<Liability> {
|
||||||
|
return prisma.liability.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.liability.delete({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total value of all liabilities for a user
|
||||||
|
*/
|
||||||
|
async getTotalValue(userId: string): Promise<number> {
|
||||||
|
const result = await prisma.liability.aggregate({
|
||||||
|
where: {userId},
|
||||||
|
_sum: {
|
||||||
|
currentBalance: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result._sum.currentBalance || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get liabilities grouped by type
|
||||||
|
*/
|
||||||
|
async getByType(userId: string): Promise<Record<string, Liability[]>> {
|
||||||
|
const liabilities = await this.findAllByUser(userId);
|
||||||
|
|
||||||
|
return liabilities.reduce((acc, liability) => {
|
||||||
|
const type = liability.type;
|
||||||
|
if (!acc[type]) {
|
||||||
|
acc[type] = [];
|
||||||
|
}
|
||||||
|
acc[type].push(liability);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Liability[]>);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
backend-api/src/repositories/NetWorthSnapshotRepository.ts
Normal file
112
backend-api/src/repositories/NetWorthSnapshotRepository.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {NetWorthSnapshot, Prisma} from '@prisma/client';
|
||||||
|
import {DatabaseConnection} from '../config/database';
|
||||||
|
import {IUserScopedRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
const prisma = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for NetWorthSnapshot data access
|
||||||
|
* Implements Single Responsibility Principle - handles only database operations
|
||||||
|
*/
|
||||||
|
export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWorthSnapshot> {
|
||||||
|
async findById(id: string): Promise<NetWorthSnapshot | null> {
|
||||||
|
return prisma.netWorthSnapshot.findUnique({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<NetWorthSnapshot[]> {
|
||||||
|
return prisma.netWorthSnapshot.findMany({
|
||||||
|
where: {userId},
|
||||||
|
orderBy: {date: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.NetWorthSnapshotCreateInput): Promise<NetWorthSnapshot> {
|
||||||
|
return prisma.netWorthSnapshot.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.NetWorthSnapshotUpdateInput): Promise<NetWorthSnapshot> {
|
||||||
|
return prisma.netWorthSnapshot.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.netWorthSnapshot.delete({
|
||||||
|
where: {id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest snapshot for a user
|
||||||
|
*/
|
||||||
|
async getLatest(userId: string): Promise<NetWorthSnapshot | null> {
|
||||||
|
return prisma.netWorthSnapshot.findFirst({
|
||||||
|
where: {userId},
|
||||||
|
orderBy: {date: 'desc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get snapshots within a date range
|
||||||
|
*/
|
||||||
|
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<NetWorthSnapshot[]> {
|
||||||
|
return prisma.netWorthSnapshot.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {date: 'asc'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a snapshot exists for a specific date
|
||||||
|
*/
|
||||||
|
async existsForDate(userId: string, date: Date): Promise<boolean> {
|
||||||
|
const count = await prisma.netWorthSnapshot.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get growth over time (percentage change between snapshots)
|
||||||
|
*/
|
||||||
|
async getGrowthStats(userId: string, limit: number = 12): Promise<any[]> {
|
||||||
|
const snapshots = await prisma.netWorthSnapshot.findMany({
|
||||||
|
where: {userId},
|
||||||
|
orderBy: {date: 'desc'},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = [];
|
||||||
|
for (let i = 0; i < snapshots.length - 1; i++) {
|
||||||
|
const current = snapshots[i];
|
||||||
|
const previous = snapshots[i + 1];
|
||||||
|
const growthAmount = current.netWorth - previous.netWorth;
|
||||||
|
const growthPercent =
|
||||||
|
previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0;
|
||||||
|
|
||||||
|
stats.push({
|
||||||
|
date: current.date,
|
||||||
|
netWorth: current.netWorth,
|
||||||
|
growthAmount,
|
||||||
|
growthPercent: parseFloat(growthPercent.toFixed(2)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend-api/src/repositories/UserRepository.ts
Normal file
51
backend-api/src/repositories/UserRepository.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {User, Prisma} from '@prisma/client';
|
||||||
|
import {prisma} from '../config/database';
|
||||||
|
import {IRepository} from './interfaces/IRepository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Repository
|
||||||
|
* Implements Single Responsibility: Only handles User data access
|
||||||
|
* Implements Dependency Inversion: Implements IRepository interface
|
||||||
|
*/
|
||||||
|
export class UserRepository implements IRepository<User> {
|
||||||
|
async findById(id: string): Promise<User | null> {
|
||||||
|
return prisma.user.findUnique({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
return prisma.user.findUnique({where: {email}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<User[]> {
|
||||||
|
return prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
password: false, // Never return password
|
||||||
|
},
|
||||||
|
}) as unknown as User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||||
|
return prisma.user.create({data});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
|
||||||
|
return prisma.user.update({
|
||||||
|
where: {id},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await prisma.user.delete({where: {id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async emailExists(email: string): Promise<boolean> {
|
||||||
|
const count = await prisma.user.count({where: {email}});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend-api/src/repositories/interfaces/IRepository.ts
Normal file
21
backend-api/src/repositories/interfaces/IRepository.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Generic Repository Interface
|
||||||
|
* Implements Interface Segregation: Base interface for common operations
|
||||||
|
* Implements Dependency Inversion: Depend on abstractions, not concretions
|
||||||
|
*/
|
||||||
|
export interface IRepository<T> {
|
||||||
|
findById(id: string): Promise<T | null>;
|
||||||
|
findAll(filters?: Record<string, any>): Promise<T[]>;
|
||||||
|
create(data: Partial<T>): Promise<T>;
|
||||||
|
update(id: string, data: Partial<T>): Promise<T>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-scoped repository interface
|
||||||
|
* For entities that belong to a specific user
|
||||||
|
*/
|
||||||
|
export interface IUserScopedRepository<T> extends Omit<IRepository<T>, 'findAll'> {
|
||||||
|
findAllByUser(userId: string, filters?: Record<string, any>): Promise<T[]>;
|
||||||
|
findByIdAndUser(id: string, userId: string): Promise<T | null>;
|
||||||
|
}
|
||||||
94
backend-api/src/routes/assets.ts
Normal file
94
backend-api/src/routes/assets.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
53
backend-api/src/routes/auth.ts
Normal file
53
backend-api/src/routes/auth.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
217
backend-api/src/routes/cashflow.routes.ts
Normal file
217
backend-api/src/routes/cashflow.routes.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
231
backend-api/src/routes/client.routes.ts
Normal file
231
backend-api/src/routes/client.routes.ts
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
106
backend-api/src/routes/dashboard.routes.ts
Normal file
106
backend-api/src/routes/dashboard.routes.ts
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
559
backend-api/src/routes/debt.routes.ts
Normal file
559
backend-api/src/routes/debt.routes.ts
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
337
backend-api/src/routes/invoice.routes.ts
Normal file
337
backend-api/src/routes/invoice.routes.ts
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
263
backend-api/src/routes/liability.routes.ts
Normal file
263
backend-api/src/routes/liability.routes.ts
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
279
backend-api/src/routes/networth.routes.ts
Normal file
279
backend-api/src/routes/networth.routes.ts
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
98
backend-api/src/server.ts
Normal file
98
backend-api/src/server.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
91
backend-api/src/services/AssetService.ts
Normal file
91
backend-api/src/services/AssetService.ts
Normal file
@@ -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<Asset[]> {
|
||||||
|
return this.assetRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string, userId: string): Promise<Asset> {
|
||||||
|
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<Asset> {
|
||||||
|
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<Asset> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
backend-api/src/services/AuthService.ts
Normal file
68
backend-api/src/services/AuthService.ts
Normal file
@@ -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<Omit<User, 'password'>> {
|
||||||
|
// 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<User> {
|
||||||
|
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<Omit<User, 'password'> | null> {
|
||||||
|
const user = await this.userRepository.findById(id);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const {password: _, ...userWithoutPassword} = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
backend-api/src/services/CashflowService.ts
Normal file
162
backend-api/src/services/CashflowService.ts
Normal file
@@ -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<IncomeSource> {
|
||||||
|
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<IncomeSource[]> {
|
||||||
|
return this.incomeRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIncomeById(id: string, userId: string): Promise<IncomeSource> {
|
||||||
|
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<CreateIncomeSourceDTO>): Promise<IncomeSource> {
|
||||||
|
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<void> {
|
||||||
|
await this.getIncomeById(id, userId);
|
||||||
|
await this.incomeRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalMonthlyIncome(userId: string): Promise<number> {
|
||||||
|
return this.incomeRepository.getTotalMonthlyIncome(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expense methods
|
||||||
|
async createExpense(userId: string, data: CreateExpenseDTO): Promise<Expense> {
|
||||||
|
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<Expense[]> {
|
||||||
|
return this.expenseRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExpenseById(id: string, userId: string): Promise<Expense> {
|
||||||
|
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<CreateExpenseDTO>): Promise<Expense> {
|
||||||
|
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<void> {
|
||||||
|
await this.getExpenseById(id, userId);
|
||||||
|
await this.expenseRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalMonthlyExpenses(userId: string): Promise<number> {
|
||||||
|
return this.expenseRepository.getTotalMonthlyExpenses(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExpensesByCategory(userId: string): Promise<Record<string, Expense[]>> {
|
||||||
|
return this.expenseRepository.getByCategory(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction methods
|
||||||
|
async createTransaction(userId: string, data: CreateTransactionDTO): Promise<Transaction> {
|
||||||
|
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<Transaction[]> {
|
||||||
|
return this.transactionRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactionById(id: string, userId: string): Promise<Transaction> {
|
||||||
|
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<void> {
|
||||||
|
await this.getTransactionById(id, userId);
|
||||||
|
await this.transactionRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactionsByDateRange(userId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
|
||||||
|
return this.transactionRepository.getByDateRange(userId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactionsByType(userId: string, type: string): Promise<Transaction[]> {
|
||||||
|
return this.transactionRepository.getByType(userId, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCashflowSummary(userId: string, startDate: Date, endDate: Date) {
|
||||||
|
return this.transactionRepository.getCashflowSummary(userId, startDate, endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
backend-api/src/services/ClientService.ts
Normal file
148
backend-api/src/services/ClientService.ts
Normal file
@@ -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<Client> {
|
||||||
|
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<Client[]> {
|
||||||
|
return this.clientRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clients with statistics
|
||||||
|
*/
|
||||||
|
async getWithStats(userId: string): Promise<any[]> {
|
||||||
|
return this.clientRepository.getWithStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single client by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string, userId: string): Promise<Client> {
|
||||||
|
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<Client> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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<number> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend-api/src/services/DashboardService.ts
Normal file
96
backend-api/src/services/DashboardService.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
168
backend-api/src/services/DebtAccountService.ts
Normal file
168
backend-api/src/services/DebtAccountService.ts
Normal file
@@ -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<DebtAccount> {
|
||||||
|
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<DebtAccount[]> {
|
||||||
|
return this.accountRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get debt accounts with statistics
|
||||||
|
*/
|
||||||
|
async getWithStats(userId: string): Promise<any[]> {
|
||||||
|
return this.accountRepository.getWithStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get debt accounts by category
|
||||||
|
*/
|
||||||
|
async getByCategory(categoryId: string, userId: string): Promise<DebtAccount[]> {
|
||||||
|
// 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<DebtAccount> {
|
||||||
|
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<DebtAccount> {
|
||||||
|
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<void> {
|
||||||
|
await this.getById(id, userId);
|
||||||
|
await this.accountRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total debt for a user
|
||||||
|
*/
|
||||||
|
async getTotalDebt(userId: string): Promise<number> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
backend-api/src/services/DebtCategoryService.ts
Normal file
156
backend-api/src/services/DebtCategoryService.ts
Normal file
@@ -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<DebtCategory[]> {
|
||||||
|
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<DebtCategory> {
|
||||||
|
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<DebtCategory[]> {
|
||||||
|
return this.categoryRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories with statistics
|
||||||
|
*/
|
||||||
|
async getWithStats(userId: string): Promise<any[]> {
|
||||||
|
return this.categoryRepository.getWithStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single category by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string, userId: string): Promise<DebtCategory> {
|
||||||
|
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<DebtCategory> {
|
||||||
|
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<void> {
|
||||||
|
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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
backend-api/src/services/DebtPaymentService.ts
Normal file
143
backend-api/src/services/DebtPaymentService.ts
Normal file
@@ -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<DebtPayment> {
|
||||||
|
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<DebtPayment[]> {
|
||||||
|
return this.paymentRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get payments by account
|
||||||
|
*/
|
||||||
|
async getByAccount(accountId: string, userId: string): Promise<DebtPayment[]> {
|
||||||
|
// 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<DebtPayment[]> {
|
||||||
|
return this.paymentRepository.getByDateRange(userId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single payment by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string, userId: string): Promise<DebtPayment> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
backend-api/src/services/InvoiceService.ts
Normal file
164
backend-api/src/services/InvoiceService.ts
Normal file
@@ -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<Invoice[]> {
|
||||||
|
return this.invoiceRepository.findAllByUser(userId, filters) as unknown as Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string, userId: string): Promise<Invoice> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<Invoice> {
|
||||||
|
return this.update(id, userId, {status});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, userId: string): Promise<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
backend-api/src/services/LiabilityService.ts
Normal file
135
backend-api/src/services/LiabilityService.ts
Normal file
@@ -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<Liability> {
|
||||||
|
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<Liability[]> {
|
||||||
|
return this.liabilityRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single liability by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string, userId: string): Promise<Liability> {
|
||||||
|
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<Liability> {
|
||||||
|
// 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<void> {
|
||||||
|
// Verify ownership
|
||||||
|
await this.getById(id, userId);
|
||||||
|
|
||||||
|
await this.liabilityRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total liability value for a user
|
||||||
|
*/
|
||||||
|
async getTotalValue(userId: string): Promise<number> {
|
||||||
|
return this.liabilityRepository.getTotalValue(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get liabilities grouped by type
|
||||||
|
*/
|
||||||
|
async getByType(userId: string): Promise<Record<string, Liability[]>> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
backend-api/src/services/NetWorthService.ts
Normal file
184
backend-api/src/services/NetWorthService.ts
Normal file
@@ -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<NetWorthSnapshot> {
|
||||||
|
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<NetWorthSnapshot> {
|
||||||
|
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<NetWorthSnapshot[]> {
|
||||||
|
return this.snapshotRepository.findAllByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get snapshots within a date range
|
||||||
|
*/
|
||||||
|
async getSnapshotsByDateRange(
|
||||||
|
userId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<NetWorthSnapshot[]> {
|
||||||
|
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<NetWorthSnapshot> {
|
||||||
|
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<void> {
|
||||||
|
await this.getById(id, userId);
|
||||||
|
await this.snapshotRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get growth statistics
|
||||||
|
*/
|
||||||
|
async getGrowthStats(userId: string, limit?: number): Promise<any[]> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend-api/src/utils/errors.ts
Normal file
38
backend-api/src/utils/errors.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
39
backend-api/src/utils/password.ts
Normal file
39
backend-api/src/utils/password.ts
Normal file
@@ -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<string> {
|
||||||
|
return bcrypt.hash(password, this.SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async compare(password: string, hash: string): Promise<boolean> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend-api/tsconfig.json
Normal file
29
backend-api/tsconfig.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
@@ -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:
|
||||||
|
|
||||||
19
env.example
Normal file
19
env.example
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import {lazy, Suspense} from 'react';
|
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 Layout from '@/components/Layout';
|
||||||
|
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||||
|
|
||||||
// Code splitting: lazy load route components
|
// Code splitting: lazy load route components
|
||||||
|
const LandingPage = lazy(() => import('@/pages/LandingPage'));
|
||||||
const NetWorthPage = lazy(() => import('@/pages/NetWorthPage'));
|
const NetWorthPage = lazy(() => import('@/pages/NetWorthPage'));
|
||||||
const CashflowPage = lazy(() => import('@/pages/CashflowPage'));
|
const CashflowPage = lazy(() => import('@/pages/CashflowPage'));
|
||||||
const DebtsPage = lazy(() => import('@/pages/DebtsPage'));
|
const DebtsPage = lazy(() => import('@/pages/DebtsPage'));
|
||||||
@@ -16,10 +19,27 @@ const PageLoader = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function App() {
|
function AppRoutes() {
|
||||||
|
const {isAuthenticated} = useAppSelector(state => state.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public route - Landing page */}
|
||||||
|
<Route
|
||||||
|
path="/welcome"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<Navigate to="/" replace />
|
||||||
|
) : (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<LandingPage />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route
|
<Route
|
||||||
index
|
index
|
||||||
@@ -62,7 +82,18 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Catch-all redirect */}
|
||||||
|
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/welcome'} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {NavLink, Outlet} from 'react-router-dom';
|
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 = [
|
const navItems = [
|
||||||
{to: '/', label: 'Net Worth', icon: TrendingUp},
|
{to: '/', label: 'Net Worth', icon: TrendingUp},
|
||||||
@@ -10,6 +11,13 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const {currentUser} = useAppSelector(state => state.user);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
dispatch(clearUser());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -35,6 +43,26 @@ export default function Layout() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* User section */}
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg">
|
||||||
|
<div className="h-7 w-7 rounded-full bg-accent flex items-center justify-center text-xs font-medium shrink-0">
|
||||||
|
{currentUser?.name?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 overflow-hidden">
|
||||||
|
<p className="text-sm font-medium truncate max-w-24">{currentUser?.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate max-w-24">{currentUser?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="h-[18px] w-[18px] shrink-0" />
|
||||||
|
<span className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">Log out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
|
|||||||
13
frontend-web/src/components/ProtectedRoute.tsx
Normal file
13
frontend-web/src/components/ProtectedRoute.tsx
Normal file
@@ -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 <Navigate to="/welcome" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
88
frontend-web/src/components/dialogs/LoginDialog.tsx
Normal file
88
frontend-web/src/components/dialogs/LoginDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="card-elevated sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Welcome back</DialogTitle>
|
||||||
|
<DialogDescription>Log in to your account</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="login-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({...form, email: e.target.value})}
|
||||||
|
className="input-depth"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="login-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm({...form, password: e.target.value})}
|
||||||
|
className="input-depth"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||||
|
<Button type="submit" className="w-full">Log in</Button>
|
||||||
|
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToSignUp}>
|
||||||
|
Don't have an account? Sign up
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend-web/src/components/dialogs/SignUpDialog.tsx
Normal file
122
frontend-web/src/components/dialogs/SignUpDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="card-elevated sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create an account</DialogTitle>
|
||||||
|
<DialogDescription>Enter your details to get started</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="signup-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="signup-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm({...form, name: e.target.value})}
|
||||||
|
className="input-depth"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="signup-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="signup-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({...form, email: e.target.value})}
|
||||||
|
className="input-depth"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="signup-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="signup-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm({...form, password: e.target.value})}
|
||||||
|
className="input-depth"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="signup-confirm">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="signup-confirm"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={e => setForm({...form, confirmPassword: e.target.value})}
|
||||||
|
className="input-depth"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
By signing up, you agree to our Terms of Service and Privacy Policy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||||
|
<Button type="submit" className="w-full">Create account</Button>
|
||||||
|
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin}>
|
||||||
|
Already have an account? Log in
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
144
frontend-web/src/pages/LandingPage.tsx
Normal file
144
frontend-web/src/pages/LandingPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-border">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-semibold">Wealth</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setLoginOpen(true)}>
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setSignUpOpen(true)}>
|
||||||
|
Sign up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="py-20 px-6">
|
||||||
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight mb-4">
|
||||||
|
Take control of your finances
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-muted-foreground mb-8 max-w-xl mx-auto">
|
||||||
|
A clean, minimal tool to track your net worth, manage debt, monitor cashflow, and invoice clients—all in one place.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button size="lg" onClick={() => setSignUpOpen(true)}>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="lg" onClick={() => setLoginOpen(true)}>
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="py-16 px-6 border-t border-border">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<h2 className="text-xl font-semibold text-center mb-10">Everything you need</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<Card key={feature.title} className="card-elevated">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<feature.icon className="h-5 w-5 mb-3 text-muted-foreground" />
|
||||||
|
<h3 className="font-medium mb-1">{feature.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="py-16 px-6 border-t border-border">
|
||||||
|
<div className="max-w-xl mx-auto text-center">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">Ready to build wealth?</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Start tracking your finances today. It's free to get started.
|
||||||
|
</p>
|
||||||
|
<Button size="lg" onClick={() => setSignUpOpen(true)}>
|
||||||
|
Create your account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Disclaimer */}
|
||||||
|
<section className="py-8 px-6 border-t border-border bg-muted/30">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<p className="text-xs text-muted-foreground text-center leading-relaxed">
|
||||||
|
<strong>Disclaimer:</strong> 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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-6 px-6 border-t border-border">
|
||||||
|
<div className="max-w-5xl mx-auto flex justify-between items-center text-sm text-muted-foreground">
|
||||||
|
<span>© {new Date().getFullYear()} Wealth</span>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<span>Privacy</span>
|
||||||
|
<span>Terms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<LoginDialog open={loginOpen} onOpenChange={setLoginOpen} onSwitchToSignUp={() => { setLoginOpen(false); setSignUpOpen(true); }} />
|
||||||
|
<SignUpDialog open={signUpOpen} onOpenChange={setSignUpOpen} onSwitchToLogin={() => { setSignUpOpen(false); setLoginOpen(true); }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user