Compare commits

..

7 Commits

Author SHA1 Message Date
df2cf418ea Enhance ESLint configuration and improve code consistency
- Added '@typescript-eslint/no-unused-vars' rule to ESLint configuration for better variable management in TypeScript files.
- Updated database.ts to ensure consistent logging format.
- Refactored AuthController and CashflowController to improve variable naming and maintainability.
- Added spacing for better readability in multiple controller methods.
- Adjusted error handling in middleware and repository files for improved clarity.
- Enhanced various service and repository methods to ensure consistent return types and error handling.
- Made minor formatting adjustments across frontend components for improved user experience.
2025-12-11 02:19:05 -05:00
40210c454e Add lock files for package management and update architecture documentation
- Introduced bun.lock and package-lock.json to manage dependencies for the project.
- Enhanced backend API architecture documentation with additional security and documentation guidelines.
- Made minor formatting adjustments across various files for consistency and clarity.
2025-12-11 02:11:43 -05:00
4911b5d125 Remove ESLint configuration and related dependencies from frontend project
- Deleted eslint.config.js file to streamline project setup.
- Removed ESLint and related packages from package.json to simplify development environment.
2025-12-11 02:11:10 -05:00
5b3d18a5b3 Add pino-pretty and update dependencies in package.json and bun.lock
- Added pino-pretty as a development dependency for enhanced logging capabilities.
- Updated various dependencies in bun.lock to their latest versions for improved performance and security.
2025-12-08 02:58:33 -05:00
ca5a13ea51 Refactor asset type handling in AssetController schema
- Replaced native enum for asset types with a constant array using z.enum for improved type safety and maintainability in create and update asset schemas.
2025-12-08 02:57:49 -05:00
700832550c Update backend API configuration and schema for improved functionality
- Modified TypeScript configuration to disable strict mode and allow importing TypeScript extensions.
- Updated Prisma schema to enhance the User model with a new debtAccounts field and refined asset/liability types.
- Adjusted environment variable parsing for PORT to use coercion for better type handling.
- Refactored various controllers and repositories to utilize type imports for better clarity and maintainability.
- Enhanced service layers with new methods for retrieving assets by type and calculating invoice statistics.
- Introduced new types for invoice statuses and asset types to ensure consistency across the application.
2025-12-08 02:57:38 -05:00
cd93dcbfd2 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.
2025-12-07 12:59:09 -05:00
105 changed files with 10729 additions and 495 deletions

810
BACKEND_PROMPT.md Normal file
View 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;
}
```

View File

@@ -0,0 +1 @@
../../CLAUDE.md

6
backend-api/.env.example Normal file
View 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
View 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

398
backend-api/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,398 @@
# 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
View 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
View 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.

337
backend-api/bun.lock Normal file
View File

@@ -0,0 +1,337 @@
{
"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",
"pino-pretty": "^13.1.3",
"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=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"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=="],
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"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=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"fast-copy": ["fast-copy@4.0.1", "", {}, "sha512-+uUOQlhsaswsizHFmEFAQhB3lSiQ+lisxl50N6ZP0wywlZeWsIESxSi9ftPEps8UGfiBzyYP7x27zA674WUvXw=="],
"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-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"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=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"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=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"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=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"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=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"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@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
"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=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"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=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"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=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"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=="],
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"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
View File

@@ -0,0 +1 @@
console.log('Hello via Bun!');

35
backend-api/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"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",
"pino-pretty": "^13.1.3",
"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"
}
}

View File

@@ -0,0 +1,244 @@
// 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[]
debtAccounts DebtAccount[]
@@map("users")
}
model Asset {
id String @id @default(uuid())
userId String
name String
type String // 'cash' | 'investment' | 'property' | 'vehicle' | 'other'
value Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("assets")
}
model Liability {
id String @id @default(uuid())
userId String
name String
type String // 'credit_card' | 'loan' | 'mortgage' | 'other'
balance Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("liabilities")
}
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 String @default("draft") // 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled'
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")
}
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 // 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | 'once'
category String
nextDate DateTime?
isActive Boolean @default(true)
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
frequency String // 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | 'once'
category String
nextDate DateTime?
isActive Boolean @default(true)
isEssential Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("expenses")
}
model Transaction {
id String @id @default(uuid())
userId String
type String // 'income' | 'expense'
name String
amount Float
category String
date DateTime
note String?
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
color String @default("#6b7280")
isDefault Boolean @default(false)
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())
userId String
categoryId String
name String
institution String?
accountNumber String? // Last 4 digits only
originalBalance Float
currentBalance Float
interestRate Float?
minimumPayment Float?
dueDay Int? // 1-31
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category DebtCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
payments DebtPayment[]
@@index([userId])
@@index([categoryId])
@@map("debt_accounts")
}
model DebtPayment {
id String @id @default(uuid())
accountId String
amount Float
date DateTime
note String?
createdAt DateTime @default(now())
account DebtAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@index([accountId, date])
@@map("debt_payments")
}

View File

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

View 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.coerce.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();

View File

@@ -0,0 +1,68 @@
import type {FastifyRequest, FastifyReply} from 'fastify';
import {z} from 'zod';
import {AssetService} from '../services/AssetService';
import {AssetRepository} from '../repositories/AssetRepository';
import {getUserId} from '../middleware/auth';
const ASSET_TYPES = ['cash', 'investment', 'property', 'vehicle', 'other'] as const;
const createAssetSchema = z.object({
name: z.string().min(1),
type: z.enum(ASSET_TYPES),
value: z.number().min(0)
});
const updateAssetSchema = z.object({
name: z.string().min(1).optional(),
type: z.enum(ASSET_TYPES).optional(),
value: z.number().min(0).optional()
});
/**
* 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();
}
}

View File

@@ -0,0 +1,72 @@
import type {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: _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});
}
}

View File

@@ -0,0 +1,206 @@
import type {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);
}
}

View File

@@ -0,0 +1,105 @@
import type {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});
}
}

View File

@@ -0,0 +1,17 @@
import type {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);
}
}

View File

@@ -0,0 +1,125 @@
import type {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});
}
}

View File

@@ -0,0 +1,97 @@
import type {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();
}
}

View File

@@ -0,0 +1,93 @@
import type {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});
}
}

View File

@@ -0,0 +1,143 @@
import type {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});
}
}

View File

@@ -0,0 +1,119 @@
import type {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});
}
}

View File

@@ -0,0 +1,122 @@
import type {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
View 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();

View File

@@ -0,0 +1,25 @@
import type {FastifyRequest, FastifyReply} from 'fastify';
import {UnauthorizedError} from '../utils/errors';
/**
* 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;
}

View File

@@ -0,0 +1,64 @@
import type {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 FastifyError & {code?: string};
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
});
}

View File

@@ -0,0 +1,49 @@
import type {Asset, Prisma} from '@prisma/client';
import {prisma} from '../config/database';
/**
* Asset Repository
* Implements Single Responsibility: Only handles Asset data access
*/
export class AssetRepository {
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, unknown>): Promise<Asset[]> {
return prisma.asset.findMany({
where: {userId, ...filters},
orderBy: {createdAt: 'desc'}
});
}
async create(data: Prisma.AssetCreateInput): Promise<Asset> {
return prisma.asset.create({data});
}
async update(id: string, data: Prisma.AssetUpdateInput): Promise<Asset> {
return prisma.asset.update({
where: {id},
data
});
}
async delete(id: string): Promise<void> {
await prisma.asset.delete({where: {id}});
}
async getTotalValue(userId: string): Promise<number> {
const result = await prisma.asset.aggregate({
where: {userId},
_sum: {value: true}
});
return result._sum.value || 0;
}
}

View File

@@ -0,0 +1,171 @@
import type {IncomeSource, Expense, Transaction, Prisma} from '@prisma/client';
import {DatabaseConnection} from '../config/database';
const prisma = DatabaseConnection.getInstance();
/**
* Repository for IncomeSource data access
*/
export class IncomeSourceRepository {
async findById(id: string): Promise<IncomeSource | null> {
return prisma.incomeSource.findUnique({where: {id}});
}
async findByIdAndUser(id: string, userId: string): Promise<IncomeSource | null> {
return prisma.incomeSource.findFirst({where: {id, userId}});
}
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 {
async findById(id: string): Promise<Expense | null> {
return prisma.expense.findUnique({where: {id}});
}
async findByIdAndUser(id: string, userId: string): Promise<Expense | null> {
return prisma.expense.findFirst({where: {id, userId}});
}
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 {
async findById(id: string): Promise<Transaction | null> {
return prisma.transaction.findUnique({where: {id}});
}
async findByIdAndUser(id: string, userId: string): Promise<Transaction | null> {
return prisma.transaction.findFirst({where: {id, userId}});
}
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 update(id: string, data: Prisma.TransactionUpdateInput): Promise<Transaction> {
return prisma.transaction.update({where: {id}, data});
}
async delete(id: string): Promise<void> {
await prisma.transaction.delete({where: {id}});
}
async getByDateRange(userId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
return prisma.transaction.findMany({
where: {
userId,
date: {gte: startDate, lte: endDate}
},
orderBy: {date: 'desc'}
});
}
async getByType(userId: string, type: string): Promise<Transaction[]> {
return prisma.transaction.findMany({
where: {userId, type},
orderBy: {date: 'desc'}
});
}
async getCashflowSummary(
userId: string,
startDate: Date,
endDate: Date
): Promise<{
totalIncome: number;
totalExpenses: number;
netCashflow: number;
}> {
const transactions = await this.getByDateRange(userId, startDate, endDate);
const totalIncome = transactions.filter(t => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
const totalExpenses = transactions.filter(t => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
return {
totalIncome,
totalExpenses,
netCashflow: totalIncome - totalExpenses
};
}
}

View File

@@ -0,0 +1,136 @@
import type {Client, Prisma} from '@prisma/client';
import {DatabaseConnection} from '../config/database';
const prisma = DatabaseConnection.getInstance();
export interface ClientStats {
totalInvoices: number;
paidInvoices: number;
totalRevenue: number;
outstandingAmount: number;
}
export interface ClientWithStats extends Client {
stats: ClientStats;
}
/**
* Repository for Client data access
* Implements Single Responsibility Principle - handles only database operations
*/
export class ClientRepository {
async findById(id: string): Promise<Client | null> {
return prisma.client.findUnique({
where: {id},
include: {
invoices: true
}
});
}
async findByIdAndUser(id: string, userId: string): Promise<Client | null> {
return prisma.client.findFirst({
where: {id, userId},
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<ClientWithStats[]> {
const clients = await prisma.client.findMany({
where: {userId},
include: {
invoices: {
select: {
id: true,
total: true,
status: true
}
}
},
orderBy: {createdAt: 'desc'}
});
return clients.map(client => ({
...client,
stats: {
totalInvoices: client.invoices.length,
paidInvoices: client.invoices.filter(inv => inv.status === 'paid').length,
totalRevenue: client.invoices.filter(inv => inv.status === 'paid').reduce((sum, inv) => sum + inv.total, 0),
outstandingAmount: client.invoices.filter(inv => inv.status !== 'paid').reduce((sum, inv) => sum + inv.total, 0)
}
}));
}
}

View File

@@ -0,0 +1,141 @@
import type {DebtAccount, Prisma} from '@prisma/client';
import {DatabaseConnection} from '../config/database';
const prisma = DatabaseConnection.getInstance();
export interface DebtAccountStats {
totalPaid: number;
numberOfPayments: number;
lastPaymentDate: Date | null;
lastPaymentAmount: number | null;
}
export interface DebtAccountWithStats extends DebtAccount {
stats: DebtAccountStats;
}
/**
* Repository for DebtAccount data access
* Implements Single Responsibility Principle - handles only database operations
*/
export class DebtAccountRepository {
async findById(id: string): Promise<DebtAccount | null> {
return prisma.debtAccount.findUnique({
where: {id},
include: {
category: true,
payments: {
orderBy: {date: 'desc'}
}
}
});
}
async findByIdAndUser(id: string, userId: string): Promise<DebtAccount | null> {
return prisma.debtAccount.findFirst({
where: {id, userId},
include: {
category: true,
payments: {
orderBy: {date: 'desc'}
}
}
});
}
async findAllByUser(userId: string): Promise<DebtAccount[]> {
return prisma.debtAccount.findMany({
where: {userId},
include: {
category: true,
payments: {
orderBy: {date: 'desc'}
}
},
orderBy: {createdAt: 'desc'}
});
}
async findByCategory(categoryId: string): Promise<DebtAccount[]> {
return prisma.debtAccount.findMany({
where: {categoryId},
include: {
payments: {
orderBy: {date: '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: {userId},
_sum: {
currentBalance: true
}
});
return result._sum.currentBalance || 0;
}
/**
* Get accounts with payment statistics
*/
async getWithStats(userId: string): Promise<DebtAccountWithStats[]> {
const accounts = await prisma.debtAccount.findMany({
where: {userId},
include: {
category: true,
payments: {
orderBy: {date: 'desc'}
}
}
});
return accounts.map(account => {
const totalPaid = account.payments.reduce((sum: number, payment: {amount: number}) => sum + payment.amount, 0);
const lastPayment = account.payments[0];
return {
...account,
stats: {
totalPaid,
numberOfPayments: account.payments.length,
lastPaymentDate: lastPayment?.date || null,
lastPaymentAmount: lastPayment?.amount || null
}
};
});
}
}

View File

@@ -0,0 +1,123 @@
import {DebtCategory, Prisma} from '@prisma/client';
import {DatabaseConnection} from '../config/database';
import {IUserScopedRepository} from './interfaces/IRepository';
const prisma = DatabaseConnection.getInstance();
export interface DebtCategoryStats {
totalAccounts: number;
totalDebt: number;
totalPayments: number;
}
export interface DebtCategoryWithStats extends DebtCategory {
stats: DebtCategoryStats;
}
/**
* Repository for DebtCategory data access
* Implements Single Responsibility Principle - handles only database operations
*/
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<DebtCategoryWithStats[]> {
const categories = await this.findAllByUser(userId);
return Promise.all(
categories.map(async category => {
const totalDebt = await this.getTotalDebt(category.id);
const totalPayments = category.accounts.reduce((sum, account) => sum + account.payments.reduce((pSum, payment) => pSum + payment.amount, 0), 0);
return {
...category,
stats: {
totalAccounts: category.accounts.length,
totalDebt,
totalPayments
}
};
})
);
}
}

View File

@@ -0,0 +1,130 @@
import type {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: {date: 'desc'}
});
}
async findAllByUser(userId: string): Promise<DebtPayment[]> {
return prisma.debtPayment.findMany({
where: {
account: {
category: {
userId
}
}
},
include: {
account: {
include: {
category: true
}
}
},
orderBy: {date: '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
}
},
date: {
gte: startDate,
lte: endDate
}
},
include: {
account: {
include: {
category: true
}
}
},
orderBy: {date: 'desc'}
});
}
}

View File

@@ -0,0 +1,79 @@
import type {Invoice, Prisma} from '@prisma/client';
type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
import {prisma} from '../config/database';
import {IUserScopedRepository} from './interfaces/IRepository';
type InvoiceWithLineItems = Prisma.InvoiceGetPayload<{
include: {lineItems: true; client: true};
}>;
/**
* Invoice Repository
* Handles Invoice data access with relationships
*/
export class InvoiceRepository implements IUserScopedRepository<Invoice> {
async findById(id: string): Promise<Invoice | null> {
return prisma.invoice.findUnique({
where: {id},
include: {lineItems: true, client: true}
}) as unknown as Invoice;
}
async findByIdAndUser(id: string, userId: string): Promise<InvoiceWithLineItems | null> {
return prisma.invoice.findFirst({
where: {id, userId},
include: {lineItems: true, client: true}
});
}
async findAllByUser(userId: string, filters?: {status?: InvoiceStatus}): Promise<InvoiceWithLineItems[]> {
return prisma.invoice.findMany({
where: {userId, ...filters},
include: {lineItems: true, client: true},
orderBy: {createdAt: 'desc'}
});
}
async create(data: Prisma.InvoiceCreateInput): Promise<Invoice> {
return prisma.invoice.create({
data,
include: {lineItems: true, client: true}
}) as unknown as Invoice;
}
async update(id: string, data: Prisma.InvoiceUpdateInput): Promise<Invoice> {
return prisma.invoice.update({
where: {id},
data,
include: {lineItems: true, client: true}
}) as unknown as Invoice;
}
async delete(id: string): Promise<void> {
await prisma.invoice.delete({where: {id}});
}
async invoiceNumberExists(userId: string, invoiceNumber: string, excludeId?: string): Promise<boolean> {
const count = await prisma.invoice.count({
where: {
userId,
invoiceNumber,
...(excludeId && {id: {not: excludeId}})
}
});
return count > 0;
}
async generateInvoiceNumber(userId: string): Promise<string> {
const year = new Date().getFullYear();
const count = await prisma.invoice.count({
where: {
userId,
invoiceNumber: {startsWith: `INV-${year}-`}
}
});
return `INV-${year}-${String(count + 1).padStart(3, '0')}`;
}
}

View File

@@ -0,0 +1,82 @@
import type {Liability, Prisma} from '@prisma/client';
import {DatabaseConnection} from '../config/database';
const prisma = DatabaseConnection.getInstance();
/**
* Repository for Liability data access
* Implements Single Responsibility Principle - handles only database operations
*/
export class LiabilityRepository {
async findById(id: string): Promise<Liability | null> {
return prisma.liability.findUnique({
where: {id}
});
}
async findByIdAndUser(id: string, userId: string): Promise<Liability | null> {
return prisma.liability.findFirst({
where: {id, userId}
});
}
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: {
balance: true
}
});
return result._sum.balance || 0;
}
/**
* Get liabilities grouped by type
*/
async getByType(userId: string): Promise<Record<string, Liability[]>> {
const liabilities = await this.findAllByUser(userId);
return liabilities.reduce(
(acc, liability) => {
const type = liability.type;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(liability);
return acc;
},
{} as Record<string, Liability[]>
);
}
}

View File

@@ -0,0 +1,118 @@
import {NetWorthSnapshot, Prisma} from '@prisma/client';
import {DatabaseConnection} from '../config/database';
import {IUserScopedRepository} from './interfaces/IRepository';
const prisma = DatabaseConnection.getInstance();
export interface GrowthStats {
date: Date;
netWorth: number;
growthAmount: number;
growthPercent: number;
}
/**
* Repository for NetWorthSnapshot data access
* Implements Single Responsibility Principle - handles only database operations
*/
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<GrowthStats[]> {
const snapshots = await prisma.netWorthSnapshot.findMany({
where: {userId},
orderBy: {date: 'desc'},
take: limit
});
const stats = [];
for (let i = 0; i < snapshots.length - 1; i++) {
const current = snapshots[i];
const previous = snapshots[i + 1];
const growthAmount = current.netWorth - previous.netWorth;
const growthPercent = previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0;
stats.push({
date: current.date,
netWorth: current.netWorth,
growthAmount,
growthPercent: parseFloat(growthPercent.toFixed(2))
});
}
return stats;
}
}

View File

@@ -0,0 +1,52 @@
import {User, Prisma} from '@prisma/client';
import {prisma} from '../config/database';
import {IRepository} from './interfaces/IRepository';
/**
* User Repository
* Implements Single Responsibility: Only handles User data access
* Implements Dependency Inversion: Implements IRepository interface
*/
export class UserRepository implements IRepository<User> {
async findById(id: string): Promise<User | null> {
return prisma.user.findUnique({where: {id}});
}
async findByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({where: {email}});
}
async findAll(): Promise<User[]> {
return prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
password: false // Never return password
}
}) as unknown as User[];
}
async create(data: Prisma.UserCreateInput): Promise<User> {
return prisma.user.create({data});
}
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
return prisma.user.update({
where: {id},
data
});
}
async delete(id: string): Promise<void> {
await prisma.user.delete({where: {id}});
}
async emailExists(email: string): Promise<boolean> {
const count = await prisma.user.count({where: {email}});
return count > 0;
}
}

View File

@@ -0,0 +1,21 @@
/**
* Generic Repository Interface
* Implements Interface Segregation: Base interface for common operations
* Implements Dependency Inversion: Depend on abstractions, not concretions
*/
export interface IRepository<T, CreateInput = unknown, UpdateInput = unknown> {
findById(id: string): Promise<T | null>;
findAll(filters?: Record<string, unknown>): Promise<T[]>;
create(data: CreateInput): Promise<T>;
update(id: string, data: UpdateInput): Promise<T>;
delete(id: string): Promise<void>;
}
/**
* User-scoped repository interface
* For entities that belong to a specific user
*/
export interface IUserScopedRepository<T, CreateInput = unknown, UpdateInput = unknown> extends Omit<IRepository<T, CreateInput, UpdateInput>, 'findAll'> {
findAllByUser(userId: string, filters?: Record<string, unknown>): Promise<T[]>;
findByIdAndUser(id: string, userId: string): Promise<T | null>;
}

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

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

View File

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

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

View File

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

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

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

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

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

102
backend-api/src/server.ts Normal file
View File

@@ -0,0 +1,102 @@
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() {
if (env.NODE_ENV !== 'production') {
console.log('Development mode enabled. Environment variables [%o]', env);
}
const fastify = Fastify({
logger: {
level: env.NODE_ENV === 'development' ? 'info' : 'error',
transport: env.NODE_ENV === 'development' ? {target: 'pino-pretty'} : undefined
}
});
// 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;
}

View File

@@ -0,0 +1,112 @@
import type {Asset} from '@prisma/client';
import {AssetRepository} from '../repositories/AssetRepository';
import {NotFoundError, ValidationError} from '../utils/errors';
type AssetType = 'cash' | 'investment' | 'property' | 'vehicle' | 'other';
const VALID_ASSET_TYPES: AssetType[] = ['cash', 'investment', 'property', 'vehicle', 'other'];
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) as AssetType,
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);
}
async getByType(userId: string): Promise<Record<string, Asset[]>> {
const assets = await this.assetRepository.findAllByUser(userId);
return assets.reduce(
(acc, asset) => {
const type = asset.type;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(asset);
return acc;
},
{} as Record<string, Asset[]>
);
}
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 (!VALID_ASSET_TYPES.includes(data.type)) {
throw new ValidationError('Invalid asset type');
}
}
}

View File

@@ -0,0 +1,70 @@
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: _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: _password, ...userWithoutPassword} = user;
return userWithoutPassword;
}
}

View File

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

View File

@@ -0,0 +1,148 @@
import {Client} from '@prisma/client';
import {ClientRepository, ClientWithStats} 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<ClientWithStats[]> {
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
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');
}
}
}
}

View File

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

View File

@@ -0,0 +1,168 @@
import {DebtAccount} from '@prisma/client';
import {DebtAccountRepository, DebtAccountWithStats} 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<DebtAccountWithStats[]> {
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');
}
}
}

View File

@@ -0,0 +1,156 @@
import {DebtCategory} from '@prisma/client';
import {DebtCategoryRepository, DebtCategoryWithStats} 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<DebtCategoryWithStats[]> {
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> {
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)');
}
}
}
}

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

View File

@@ -0,0 +1,226 @@
import type {Invoice, Prisma} from '@prisma/client';
import {InvoiceRepository} from '../repositories/InvoiceRepository';
import {NotFoundError, ValidationError} from '../utils/errors';
type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
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;
}
interface InvoiceStats {
total: number;
draft: number;
sent: number;
paid: number;
overdue: number;
totalAmount: number;
paidAmount: number;
outstandingAmount: number;
}
/**
* 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 getAllByUser(userId: string, filters?: {status?: string; clientId?: string}): 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 || '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);
}
async getStats(userId: string): Promise<InvoiceStats> {
const invoices = await this.invoiceRepository.findAllByUser(userId);
const stats: InvoiceStats = {
total: invoices.length,
draft: 0,
sent: 0,
paid: 0,
overdue: 0,
totalAmount: 0,
paidAmount: 0,
outstandingAmount: 0
};
for (const inv of invoices) {
stats.totalAmount += inv.total;
switch (inv.status) {
case 'draft':
stats.draft++;
break;
case 'sent':
stats.sent++;
stats.outstandingAmount += inv.total;
break;
case 'paid':
stats.paid++;
stats.paidAmount += inv.total;
break;
case 'overdue':
stats.overdue++;
stats.outstandingAmount += inv.total;
break;
}
}
return stats;
}
async getOverdueInvoices(userId: string): Promise<Invoice[]> {
const invoices = await this.invoiceRepository.findAllByUser(userId, {status: 'overdue'});
return invoices as unknown as Invoice[];
}
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');
}
}
}
}

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

View File

@@ -0,0 +1,177 @@
import {NetWorthSnapshot} from '@prisma/client';
import {NetWorthSnapshotRepository, GrowthStats} 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<GrowthStats[]> {
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');
}
}
}

14
backend-api/src/types/fastify.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import '@fastify/jwt';
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
id: string;
email: string;
};
user: {
id: string;
email: string;
};
}
}

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

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

30
backend-api/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"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": false,
"noEmit": true,
// Best practices
"strict": false,
"strictNullChecks": false,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

355
bun.lock Normal file
View File

@@ -0,0 +1,355 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "personal-finances",
"dependencies": {
"concurrently": "^9.2.1",
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript-eslint": "^8.46.4",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
"@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.49.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/type-utils": "8.49.0", "@typescript-eslint/utils": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.49.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.49.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.49.0", "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.49.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.49.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.49.0", "@typescript-eslint/tsconfig-utils": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.49.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.6", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001760", "", {}, "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.49.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.49.0", "@typescript-eslint/parser": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
}
}

66
docker-compose.yml Normal file
View 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
View 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

43
eslint.config.js Normal file
View File

@@ -0,0 +1,43 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import {defineConfig, globalIgnores} from 'eslint/config';
// Shared rules for all TypeScript files
const sharedRules = {
'padding-line-between-statements': ['error', {blankLine: 'always', prev: '*', next: 'return'}],
'@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_'}]
};
export default defineConfig([
globalIgnores(['**/dist', '**/node_modules']),
// Backend API - TypeScript files
{
files: ['backend-api/**/*.ts'],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: globals.node
},
rules: sharedRules
},
// Frontend Web - TypeScript/React files
{
files: ['frontend-web/**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
},
rules: sharedRules
}
]);

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000

View File

@@ -12,6 +12,10 @@ dist
dist-ssr
*.local
# Environment variables
.env
.env.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@@ -1,18 +0,0 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import {defineConfig, globalIgnores} from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
}
}
]);

View File

@@ -6,7 +6,6 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
@@ -33,22 +32,16 @@
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -1,8 +1,14 @@
import {lazy, Suspense} from 'react';
import {BrowserRouter, Routes, Route} from 'react-router-dom';
import {lazy, Suspense, useEffect} from 'react';
import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom';
import {useAppSelector, useAppDispatch} from '@/store';
import {loadUserFromStorage} from '@/store/slices/userSlice';
import {fetchAssets, fetchLiabilities, fetchSnapshots} from '@/store/slices/netWorthSlice';
import {fetchIncomeSources, fetchExpenses, fetchTransactions} from '@/store/slices/cashflowSlice';
import Layout from '@/components/Layout';
import ProtectedRoute from '@/components/ProtectedRoute';
// Code splitting: lazy load route components
const LandingPage = lazy(() => import('@/pages/LandingPage'));
const NetWorthPage = lazy(() => import('@/pages/NetWorthPage'));
const CashflowPage = lazy(() => import('@/pages/CashflowPage'));
const DebtsPage = lazy(() => import('@/pages/DebtsPage'));
@@ -16,10 +22,50 @@ const PageLoader = () => (
</div>
);
export default function App() {
function AppRoutes() {
const dispatch = useAppDispatch();
const {isAuthenticated, isLoading} = useAppSelector(state => state.user);
// Load user from storage on app start
useEffect(() => {
dispatch(loadUserFromStorage());
}, [dispatch]);
// Fetch all data when user is authenticated
useEffect(() => {
if (isAuthenticated) {
dispatch(fetchAssets());
dispatch(fetchLiabilities());
dispatch(fetchSnapshots());
dispatch(fetchIncomeSources());
dispatch(fetchExpenses());
dispatch(fetchTransactions());
}
}, [isAuthenticated, dispatch]);
// Show loading while checking authentication
if (isLoading) {
return <PageLoader />;
}
return (
<BrowserRouter>
<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
index
@@ -62,7 +108,18 @@ export default function App() {
}
/>
</Route>
</Route>
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/welcome'} replace />} />
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}

View File

@@ -1,5 +1,6 @@
import {NavLink, Outlet} from 'react-router-dom';
import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight} from 'lucide-react';
import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight, LogOut} from 'lucide-react';
import {useAppSelector, useAppDispatch, clearUser} from '@/store';
const navItems = [
{to: '/', label: 'Net Worth', icon: TrendingUp},
@@ -10,6 +11,13 @@ const navItems = [
];
export default function Layout() {
const dispatch = useAppDispatch();
const {currentUser} = useAppSelector(state => state.user);
const handleLogout = () => {
dispatch(clearUser());
};
return (
<div className="flex min-h-screen">
{/* Sidebar */}
@@ -35,6 +43,26 @@ export default function Layout() {
</NavLink>
))}
</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>
{/* Main content */}

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

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, addAsset} from '@/store';
import {useAppDispatch, createAsset} from '@/store';
import {validatePositiveNumber, validateRequired, sanitizeString} from '@/lib/validation';
interface Props {
@@ -35,6 +35,7 @@ export default function AddAssetDialog({open, onOpenChange}: Props) {
}
setErrors(newErrors);
return isValid;
};
@@ -46,12 +47,10 @@ export default function AddAssetDialog({open, onOpenChange}: Props) {
if (valueNum === null) return;
dispatch(
addAsset({
id: crypto.randomUUID(),
createAsset({
name: sanitizeString(form.name),
type: form.type as (typeof assetTypes)[number],
type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER',
value: valueNum,
updatedAt: new Date().toISOString()
})
);
onOpenChange(false);

View File

@@ -1,4 +1,4 @@
import {useState} from 'react';
import {useState, useEffect} from 'react';
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Button} from '@/components/ui/button';
@@ -11,6 +11,10 @@ interface Props {
onOpenChange: (open: boolean) => void;
}
function getDefaultDueDate(): string {
return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
}
export default function AddInvoiceDialog({open, onOpenChange}: Props) {
const dispatch = useAppDispatch();
const {clients} = useAppSelector(state => state.invoices);
@@ -18,9 +22,17 @@ export default function AddInvoiceDialog({open, onOpenChange}: Props) {
clientId: '',
description: '',
amount: '',
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
dueDate: getDefaultDueDate()
});
// Reset form with fresh due date when dialog opens
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog
setForm(prev => ({...prev, dueDate: getDefaultDueDate()}));
}
}, [open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const now = new Date().toISOString();
@@ -52,7 +64,7 @@ export default function AddInvoiceDialog({open, onOpenChange}: Props) {
})
);
onOpenChange(false);
setForm({clientId: '', description: '', amount: '', dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]});
setForm({clientId: '', description: '', amount: '', dueDate: getDefaultDueDate()});
};
return (

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, addLiability} from '@/store';
import {useAppDispatch, createLiability} from '@/store';
interface Props {
open: boolean;
@@ -20,12 +20,10 @@ export default function AddLiabilityDialog({open, onOpenChange}: Props) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
dispatch(
addLiability({
id: crypto.randomUUID(),
createLiability({
name: form.name,
type: form.type as (typeof liabilityTypes)[number],
balance: parseFloat(form.balance) || 0,
updatedAt: new Date().toISOString()
type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER',
currentBalance: parseFloat(form.balance) || 0,
})
);
onOpenChange(false);

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, updateAsset, removeAsset, type Asset} from '@/store';
import {useAppDispatch, updateAsset, deleteAsset, type Asset} from '@/store';
import {validatePositiveNumber, validateRequired} from '@/lib/validation';
interface Props {
@@ -20,8 +20,10 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
const [form, setForm] = useState({name: '', type: '', value: ''});
const [errors, setErrors] = useState({name: '', value: ''});
// Sync form state when asset changes - intentional pattern for controlled form dialogs
useEffect(() => {
if (asset) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog
setForm({
name: asset.name,
type: asset.type,
@@ -47,6 +49,7 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
}
setErrors(newErrors);
return isValid;
};
@@ -60,10 +63,11 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
dispatch(
updateAsset({
id: asset.id,
data: {
name: form.name.trim(),
type: form.type as (typeof assetTypes)[number],
type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER',
value: valueNum,
updatedAt: new Date().toISOString()
}
})
);
onOpenChange(false);
@@ -72,7 +76,7 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
const handleDelete = () => {
if (!asset) return;
if (confirm(`Are you sure you want to delete "${asset.name}"?`)) {
dispatch(removeAsset(asset.id));
dispatch(deleteAsset(asset.id));
onOpenChange(false);
}
};

View File

@@ -24,8 +24,10 @@ export default function EditClientDialog({open, onOpenChange, client}: Props) {
});
const [errors, setErrors] = useState({name: '', email: '', phone: ''});
// Sync form state when client changes - intentional pattern for controlled form dialogs
useEffect(() => {
if (client) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog
setForm({
name: client.name,
email: client.email,
@@ -58,6 +60,7 @@ export default function EditClientDialog({open, onOpenChange, client}: Props) {
}
setErrors(newErrors);
return isValid;
};

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, updateLiability, removeLiability, type Liability} from '@/store';
import {useAppDispatch, updateLiability, deleteLiability, type Liability} from '@/store';
import {validatePositiveNumber, validateRequired} from '@/lib/validation';
interface Props {
@@ -20,8 +20,10 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
const [form, setForm] = useState({name: '', type: '', balance: ''});
const [errors, setErrors] = useState({name: '', balance: ''});
// Sync form state when liability changes - intentional pattern for controlled form dialogs
useEffect(() => {
if (liability) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog
setForm({
name: liability.name,
type: liability.type,
@@ -47,6 +49,7 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
}
setErrors(newErrors);
return isValid;
};
@@ -60,10 +63,11 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
dispatch(
updateLiability({
id: liability.id,
data: {
name: form.name.trim(),
type: form.type as (typeof liabilityTypes)[number],
balance: balanceNum,
updatedAt: new Date().toISOString()
type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER',
currentBalance: balanceNum,
}
})
);
onOpenChange(false);
@@ -72,7 +76,7 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
const handleDelete = () => {
if (!liability) return;
if (confirm(`Are you sure you want to delete "${liability.name}"?`)) {
dispatch(removeLiability(liability.id));
dispatch(deleteLiability(liability.id));
onOpenChange(false);
}
};

View File

@@ -25,8 +25,10 @@ export default function InvoiceDetailsDialog({open, onOpenChange, invoice, clien
const dispatch = useAppDispatch();
const [selectedStatus, setSelectedStatus] = useState<Invoice['status']>('draft');
// Sync status when invoice changes - intentional pattern for controlled form dialogs
useEffect(() => {
if (invoice) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional pattern for form dialog
setSelectedStatus(invoice.status);
}
}, [invoice]);

View File

@@ -0,0 +1,100 @@
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} from '@/store';
import {loginUser} from '@/store/slices/userSlice';
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 [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!form.email || !form.password) {
setError('Please enter your email and password');
return;
}
setIsLoading(true);
try {
await dispatch(
loginUser({
email: form.email,
password: form.password,
})
).unwrap();
onOpenChange(false);
setForm({email: '', password: ''});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message || 'Login failed. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
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" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log in'}
</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToSignUp} disabled={isLoading}>
Don't have an account? Sign up
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,137 @@
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} from '@/store';
import {registerUser} from '@/store/slices/userSlice';
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 [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (form.password !== form.confirmPassword) {
setError('Passwords do not match');
return;
}
if (form.password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
try {
await dispatch(
registerUser({
email: form.email,
password: form.password,
name: form.name,
})
).unwrap();
onOpenChange(false);
setForm({name: '', email: '', password: '', confirmPassword: ''});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message || 'Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
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" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin} disabled={isLoading}>
Already have an account? Log in
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from 'react';
import {Slot} from '@radix-ui/react-slot';
import {cva, type VariantProps} from 'class-variance-authority';

View File

@@ -0,0 +1,73 @@
/**
* Authentication Service
*/
import {apiClient} from './client';
import {tokenStorage} from './token';
export interface RegisterRequest {
email: string;
password: string;
name: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface AuthResponse {
token: string;
user: {
id: string;
email: string;
name: string;
};
}
export interface UserProfile {
id: string;
email: string;
name: string;
createdAt: string;
}
export const authService = {
async register(data: RegisterRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/register', data);
tokenStorage.setToken(response.token);
tokenStorage.setUser(JSON.stringify(response.user));
return response;
},
async login(data: LoginRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/login', data);
tokenStorage.setToken(response.token);
tokenStorage.setUser(JSON.stringify(response.user));
return response;
},
async getProfile(): Promise<UserProfile> {
return apiClient.get<UserProfile>('/profile');
},
logout(): void {
tokenStorage.clear();
},
isAuthenticated(): boolean {
return !!tokenStorage.getToken();
},
getCurrentUser(): {id: string; email: string; name: string} | null {
const userStr = tokenStorage.getUser();
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
},
};

View File

@@ -0,0 +1,88 @@
/**
* Cashflow Service
*/
import {apiClient} from './client';
export interface IncomeSource {
id: string;
name: string;
amount: number;
frequency: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY' | 'ONCE';
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Expense {
id: string;
name: string;
amount: number;
frequency: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY' | 'ONCE';
category?: string;
isEssential?: boolean;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Transaction {
id: string;
type: 'INCOME' | 'EXPENSE';
amount: number;
description: string;
category?: string;
date: string;
notes?: string;
createdAt?: string;
}
export const incomeService = {
async getAll(): Promise<{incomeSources: IncomeSource[]}> {
return apiClient.get<{incomeSources: IncomeSource[]}>('/cashflow/income');
},
async create(data: Partial<IncomeSource>): Promise<{incomeSource: IncomeSource}> {
return apiClient.post<{incomeSource: IncomeSource}>('/cashflow/income', data);
},
async update(id: string, data: Partial<IncomeSource>): Promise<{incomeSource: IncomeSource}> {
return apiClient.put<{incomeSource: IncomeSource}>(`/cashflow/income/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/cashflow/income/${id}`);
},
};
export const expenseService = {
async getAll(): Promise<{expenses: Expense[]}> {
return apiClient.get<{expenses: Expense[]}>('/cashflow/expenses');
},
async create(data: Partial<Expense>): Promise<{expense: Expense}> {
return apiClient.post<{expense: Expense}>('/cashflow/expenses', data);
},
async update(id: string, data: Partial<Expense>): Promise<{expense: Expense}> {
return apiClient.put<{expense: Expense}>(`/cashflow/expenses/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/cashflow/expenses/${id}`);
},
};
export const transactionService = {
async getAll(): Promise<{transactions: Transaction[]}> {
return apiClient.get<{transactions: Transaction[]}>('/cashflow/transactions');
},
async create(data: Partial<Transaction>): Promise<{transaction: Transaction}> {
return apiClient.post<{transaction: Transaction}>('/cashflow/transactions', data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/cashflow/transactions/${id}`);
},
};

View File

@@ -0,0 +1,111 @@
/**
* API Client
* Base configuration for all API requests
*/
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export interface ApiError {
message: string;
statusCode: number;
error?: string;
}
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private getAuthToken(): string | null {
return localStorage.getItem('auth_token');
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const token = this.getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(url, {
...options,
headers,
});
// Handle non-JSON responses
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
if (!response.ok) {
throw {
message: 'Request failed',
statusCode: response.status,
error: response.statusText,
} as ApiError;
}
return {} as T;
}
const data = await response.json();
if (!response.ok) {
throw {
message: data.message || 'Request failed',
statusCode: response.status,
error: data.error,
} as ApiError;
}
return data;
} catch (error) {
if ((error as ApiError).statusCode) {
throw error;
}
throw {
message: 'Network error',
statusCode: 0,
error: String(error),
} as ApiError;
}
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, {method: 'GET'});
}
async post<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, {method: 'DELETE'});
}
}
export const apiClient = new ApiClient(API_URL);

View File

@@ -0,0 +1,144 @@
/**
* Net Worth Service
*/
import {apiClient} from './client';
export interface Asset {
id: string;
name: string;
type: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER';
value: number;
createdAt?: string;
updatedAt?: string;
}
export interface CreateAssetRequest {
name: string;
type: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER';
value: number;
}
export interface UpdateAssetRequest {
name?: string;
type?: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER';
value?: number;
}
export interface Liability {
id: string;
name: string;
type: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER';
currentBalance: number;
interestRate?: number;
minimumPayment?: number;
dueDate?: string;
creditor?: string;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateLiabilityRequest {
name: string;
type: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER';
currentBalance: number;
interestRate?: number;
minimumPayment?: number;
dueDate?: string;
creditor?: string;
notes?: string;
}
export interface UpdateLiabilityRequest {
name?: string;
type?: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER';
currentBalance?: number;
interestRate?: number;
minimumPayment?: number;
dueDate?: string;
creditor?: string;
notes?: string;
}
export interface NetWorthSnapshot {
id: string;
date: string;
totalAssets: number;
totalLiabilities: number;
netWorth: number;
notes?: string;
createdAt?: string;
}
export const assetService = {
async getAll(): Promise<{assets: Asset[]}> {
return apiClient.get<{assets: Asset[]}>('/assets');
},
async getById(id: string): Promise<{asset: Asset}> {
return apiClient.get<{asset: Asset}>(`/assets/${id}`);
},
async create(data: CreateAssetRequest): Promise<{asset: Asset}> {
return apiClient.post<{asset: Asset}>('/assets', data);
},
async update(id: string, data: UpdateAssetRequest): Promise<{asset: Asset}> {
return apiClient.put<{asset: Asset}>(`/assets/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/assets/${id}`);
},
};
export const liabilityService = {
async getAll(): Promise<{liabilities: Liability[]}> {
return apiClient.get<{liabilities: Liability[]}>('/liabilities');
},
async getById(id: string): Promise<{liability: Liability}> {
return apiClient.get<{liability: Liability}>(`/liabilities/${id}`);
},
async create(data: CreateLiabilityRequest): Promise<{liability: Liability}> {
return apiClient.post<{liability: Liability}>('/liabilities', data);
},
async update(id: string, data: UpdateLiabilityRequest): Promise<{liability: Liability}> {
return apiClient.put<{liability: Liability}>(`/liabilities/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/liabilities/${id}`);
},
};
export const snapshotService = {
async getAll(): Promise<{snapshots: NetWorthSnapshot[]}> {
return apiClient.get<{snapshots: NetWorthSnapshot[]}>('/networth/snapshots');
},
async getById(id: string): Promise<{snapshot: NetWorthSnapshot}> {
return apiClient.get<{snapshot: NetWorthSnapshot}>(`/networth/snapshots/${id}`);
},
async create(data: {
date: string;
totalAssets: number;
totalLiabilities: number;
netWorth: number;
notes?: string;
}): Promise<{snapshot: NetWorthSnapshot}> {
return apiClient.post<{snapshot: NetWorthSnapshot}>('/networth/snapshots', data);
},
async createFromCurrent(notes?: string): Promise<{snapshot: NetWorthSnapshot}> {
return apiClient.post<{snapshot: NetWorthSnapshot}>('/networth/snapshots/record', {notes});
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/networth/snapshots/${id}`);
},
};

View File

@@ -0,0 +1,37 @@
/**
* Token Storage Utilities
*/
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export const tokenStorage = {
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
},
removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
},
getUser(): string | null {
return localStorage.getItem(USER_KEY);
},
setUser(user: string): void {
localStorage.setItem(USER_KEY, user);
},
removeUser(): void {
localStorage.removeItem(USER_KEY);
},
clear(): void {
this.removeToken();
this.removeUser();
},
};

View File

@@ -32,6 +32,7 @@ export const calculateYTDGrowth = (snapshots: NetWorthSnapshot[]): number => {
const currentValue = ytdSnapshots[ytdSnapshots.length - 1].netWorth;
if (startValue === 0) return 0;
return ((currentValue - startValue) / Math.abs(startValue)) * 100;
}
@@ -39,6 +40,7 @@ export const calculateYTDGrowth = (snapshots: NetWorthSnapshot[]): number => {
const currentValue = ytdSnapshots[ytdSnapshots.length - 1].netWorth;
if (startValue === 0) return 0;
return ((currentValue - startValue) / Math.abs(startValue)) * 100;
};
@@ -50,10 +52,12 @@ export const calculateAllTimeGrowth = (snapshots: NetWorthSnapshot[]): number =>
const last = sorted[sorted.length - 1];
if (first.netWorth === 0) return 0;
return ((last.netWorth - first.netWorth) / Math.abs(first.netWorth)) * 100;
};
export const calculateSavingsRate = (totalIncome: number, totalExpenses: number): number => {
if (totalIncome === 0) return 0;
return ((totalIncome - totalExpenses) / totalIncome) * 100;
};

View File

@@ -17,6 +17,7 @@ export const formatCurrencyCompact = (value: number): string => {
if (Math.abs(value) >= 1000) {
return `$${(value / 1000).toFixed(0)}k`;
}
return formatCurrency(value);
};

View File

@@ -13,24 +13,28 @@ export const sanitizeString = (input: string): string => {
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
export const validatePhone = (phone: string): boolean => {
// Accepts various phone formats
const phoneRegex = /^[\d\s\-\(\)\+]+$/;
const phoneRegex = /^[\d\s\-()+]+$/;
return phoneRegex.test(phone) && phone.replace(/\D/g, '').length >= 10;
};
export const validateNumber = (value: string): number | null => {
const parsed = parseFloat(value);
if (isNaN(parsed)) return null;
return parsed;
};
export const validatePositiveNumber = (value: string): number | null => {
const num = validateNumber(value);
if (num === null || num < 0) return null;
return num;
};
@@ -42,6 +46,7 @@ export const validateInvoiceNumber = (invoiceNumber: string, existingNumbers: st
if (!validateRequired(invoiceNumber)) return false;
// Check uniqueness
const sanitized = sanitizeString(invoiceNumber);
return !existingNumbers.some(num => num === sanitized);
};

View File

@@ -42,6 +42,7 @@ export default function CashflowPage() {
(acc, e) => {
const monthly = getMonthlyAmount(e.amount, e.frequency);
acc[e.category] = (acc[e.category] || 0) + monthly;
return acc;
},
{} as Record<string, number>
@@ -115,6 +116,7 @@ export default function CashflowPage() {
<div className="space-y-2">
{sortedCategories.map(([category, amount]) => {
const pct = (amount / monthlyExpenses) * 100;
return (
<div key={category} className="flex items-center gap-3">
<div className="w-24 text-sm truncate">{category}</div>

View File

@@ -16,6 +16,7 @@ export default function ClientsPage() {
const clientInvoices = invoices.filter(i => i.clientId === clientId);
const totalBilled = clientInvoices.reduce((sum, i) => sum + i.total, 0);
const outstanding = clientInvoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((sum, i) => sum + i.total, 0);
return {totalBilled, outstanding, count: clientInvoices.length};
};
@@ -54,6 +55,7 @@ export default function ClientsPage() {
<div className="grid grid-cols-3 gap-4">
{clients.map(client => {
const stats = getClientStats(client.id);
return (
<Card key={client.id} className="card-elevated cursor-pointer hover:bg-accent/30 transition-colors" onClick={() => handleEditClient(client)}>
<CardContent className="p-4">

View File

@@ -114,6 +114,7 @@ export default function DebtsPage() {
{accounts.map(account => {
const category = getCategoryById(account.categoryId);
const progress = getProgress(account);
return (
<Card key={account.id} className="card-elevated">
<CardContent className="p-3">

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

View File

@@ -1,7 +1,7 @@
import {useState} from 'react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector, useAppDispatch, addSnapshot, type Asset, type Liability} from '@/store';
import {useAppSelector, type Asset, type Liability} from '@/store';
import {AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer} from 'recharts';
import {format} from 'date-fns';
import AddAssetDialog from '@/components/dialogs/AddAssetDialog';
@@ -12,7 +12,6 @@ import {formatCurrency, formatPercentage} from '@/lib/formatters';
import {calculateMonthlyChange, calculateYTDGrowth} from '@/lib/calculations';
export default function NetWorthPage() {
const dispatch = useAppDispatch();
const [assetDialogOpen, setAssetDialogOpen] = useState(false);
const [liabilityDialogOpen, setLiabilityDialogOpen] = useState(false);
const [editAssetDialogOpen, setEditAssetDialogOpen] = useState(false);
@@ -34,14 +33,8 @@ export default function NetWorthPage() {
const ytdGrowth = calculateYTDGrowth(snapshots);
const handleRecordSnapshot = () => {
const snapshot = {
id: crypto.randomUUID(),
date: new Date().toISOString().split('T')[0],
totalAssets,
totalLiabilities,
netWorth
};
dispatch(addSnapshot(snapshot));
// TODO: Implement createSnapshot thunk
console.log('Record snapshot functionality not yet implemented');
};
const handleEditAsset = (asset: Asset) => {
@@ -199,6 +192,7 @@ export default function NetWorthPage() {
const total = assets.filter(a => a.type === type).reduce((s, a) => s + a.value, 0);
const pct = totalAssets > 0 ? (total / totalAssets) * 100 : 0;
if (total === 0) return null;
return (
<div key={type} className="flex items-center gap-2">
<div className="flex-1">

View File

@@ -13,14 +13,15 @@ export type {User, UserState} from './slices/userSlice';
export {
setLoading as setNetWorthLoading,
setError as setNetWorthError,
addAsset,
fetchAssets,
createAsset,
updateAsset,
removeAsset,
addLiability,
deleteAsset,
fetchLiabilities,
createLiability,
updateLiability,
removeLiability,
addSnapshot,
setSnapshots
deleteLiability,
fetchSnapshots
} from './slices/netWorthSlice';
export type {Asset, Liability, NetWorthSnapshot, NetWorthState} from './slices/netWorthSlice';

View File

@@ -1,4 +1,5 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit';
import {incomeService, expenseService, transactionService, type IncomeSource as ApiIncome, type Expense as ApiExpense, type Transaction as ApiTransaction} from '@/lib/api/cashflow.service';
export interface IncomeSource {
id: string;
@@ -47,162 +48,86 @@ const defaultCategories = {
expense: ['Housing', 'Utilities', 'Transportation', 'Food', 'Insurance', 'Healthcare', 'Subscriptions', 'Entertainment', 'Shopping', 'Savings', 'Other']
};
// Mock data
const mockIncomeSources: IncomeSource[] = [
{
id: 'i1',
name: 'Software Engineer Salary',
amount: 8500,
frequency: 'monthly',
category: 'Salary',
nextDate: '2024-12-15',
isActive: true,
createdAt: '2024-01-01'
},
{id: 'i2', name: 'Consulting', amount: 2000, frequency: 'monthly', category: 'Freelance', nextDate: '2024-12-20', isActive: true, createdAt: '2024-03-01'},
{
id: 'i3',
name: 'Dividend Income',
amount: 450,
frequency: 'quarterly',
category: 'Investments',
nextDate: '2024-12-31',
isActive: true,
createdAt: '2024-01-01'
}
];
const mockExpenses: Expense[] = [
{
id: 'e1',
name: 'Mortgage',
amount: 2200,
frequency: 'monthly',
category: 'Housing',
nextDate: '2024-12-01',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e2',
name: 'Car Payment',
amount: 450,
frequency: 'monthly',
category: 'Transportation',
nextDate: '2024-12-05',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e3',
name: 'Car Insurance',
amount: 180,
frequency: 'monthly',
category: 'Insurance',
nextDate: '2024-12-10',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e4',
name: 'Utilities',
amount: 250,
frequency: 'monthly',
category: 'Utilities',
nextDate: '2024-12-15',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e5',
name: 'Groceries',
amount: 600,
frequency: 'monthly',
category: 'Food',
nextDate: '2024-12-01',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e6',
name: 'Gym Membership',
amount: 50,
frequency: 'monthly',
category: 'Healthcare',
nextDate: '2024-12-01',
isActive: true,
isEssential: false,
createdAt: '2024-01-01'
},
{
id: 'e7',
name: 'Netflix',
amount: 15,
frequency: 'monthly',
category: 'Subscriptions',
nextDate: '2024-12-08',
isActive: true,
isEssential: false,
createdAt: '2024-01-01'
},
{
id: 'e8',
name: 'Spotify',
amount: 12,
frequency: 'monthly',
category: 'Subscriptions',
nextDate: '2024-12-12',
isActive: true,
isEssential: false,
createdAt: '2024-01-01'
},
{
id: 'e9',
name: 'Health Insurance',
amount: 350,
frequency: 'monthly',
category: 'Insurance',
nextDate: '2024-12-01',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e10',
name: '401k Contribution',
amount: 1500,
frequency: 'monthly',
category: 'Savings',
nextDate: '2024-12-15',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
}
];
const mockTransactions: Transaction[] = [
{id: 't1', type: 'income', name: 'Salary', amount: 8500, category: 'Salary', date: '2024-11-15'},
{id: 't2', type: 'expense', name: 'Mortgage', amount: 2200, category: 'Housing', date: '2024-11-01'},
{id: 't3', type: 'expense', name: 'Groceries', amount: 145, category: 'Food', date: '2024-11-28'},
{id: 't4', type: 'expense', name: 'Gas', amount: 55, category: 'Transportation', date: '2024-11-25'},
{id: 't5', type: 'income', name: 'Consulting Payment', amount: 2000, category: 'Freelance', date: '2024-11-20'},
{id: 't6', type: 'expense', name: 'Restaurant', amount: 85, category: 'Food', date: '2024-11-22'}
];
const initialState: CashflowState = {
incomeSources: mockIncomeSources,
expenses: mockExpenses,
transactions: mockTransactions,
incomeSources: [],
expenses: [],
transactions: [],
categories: defaultCategories,
isLoading: false,
error: null
};
// Helper mappers
const mapApiIncomeToIncome = (apiIncome: ApiIncome): IncomeSource => ({
id: apiIncome.id,
name: apiIncome.name,
amount: apiIncome.amount,
frequency: apiIncome.frequency.toLowerCase() as IncomeSource['frequency'],
category: 'Income',
nextDate: new Date().toISOString(),
isActive: true,
createdAt: apiIncome.createdAt || new Date().toISOString(),
});
const mapApiExpenseToExpense = (apiExpense: ApiExpense): Expense => ({
id: apiExpense.id,
name: apiExpense.name,
amount: apiExpense.amount,
frequency: apiExpense.frequency.toLowerCase() as Expense['frequency'],
category: apiExpense.category || 'Other',
nextDate: new Date().toISOString(),
isActive: true,
isEssential: apiExpense.isEssential || false,
createdAt: apiExpense.createdAt || new Date().toISOString(),
});
const mapApiTransactionToTransaction = (apiTransaction: ApiTransaction): Transaction => ({
id: apiTransaction.id,
type: apiTransaction.type.toLowerCase() as Transaction['type'],
name: apiTransaction.description,
amount: apiTransaction.amount,
category: apiTransaction.category || 'Other',
date: apiTransaction.date,
note: apiTransaction.notes,
});
// Async thunks
export const fetchIncomeSources = createAsyncThunk('cashflow/fetchIncomeSources', async (_, {rejectWithValue}) => {
try {
const response = await incomeService.getAll();
return response.incomeSources.map(mapApiIncomeToIncome);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch income sources';
return rejectWithValue(message);
}
});
export const fetchExpenses = createAsyncThunk('cashflow/fetchExpenses', async (_, {rejectWithValue}) => {
try {
const response = await expenseService.getAll();
return response.expenses.map(mapApiExpenseToExpense);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch expenses';
return rejectWithValue(message);
}
});
export const fetchTransactions = createAsyncThunk('cashflow/fetchTransactions', async (_, {rejectWithValue}) => {
try {
const response = await transactionService.getAll();
return response.transactions.map(mapApiTransactionToTransaction);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch transactions';
return rejectWithValue(message);
}
});
const cashflowSlice = createSlice({
name: 'cashflow',
initialState,
@@ -242,7 +167,50 @@ const cashflowSlice = createSlice({
removeTransaction: (state, action: PayloadAction<string>) => {
state.transactions = state.transactions.filter(t => t.id !== action.payload);
}
}
},
extraReducers: builder => {
// Fetch income sources
builder.addCase(fetchIncomeSources.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchIncomeSources.fulfilled, (state, action) => {
state.isLoading = false;
state.incomeSources = action.payload;
});
builder.addCase(fetchIncomeSources.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Fetch expenses
builder.addCase(fetchExpenses.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchExpenses.fulfilled, (state, action) => {
state.isLoading = false;
state.expenses = action.payload;
});
builder.addCase(fetchExpenses.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Fetch transactions
builder.addCase(fetchTransactions.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchTransactions.fulfilled, (state, action) => {
state.isLoading = false;
state.transactions = action.payload;
});
builder.addCase(fetchTransactions.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const {

View File

@@ -50,104 +50,10 @@ const defaultCategories: DebtCategory[] = [
{id: 'other', name: 'Other', color: '#6b7280', createdAt: new Date().toISOString()}
];
// Mock data for development
const mockAccounts: DebtAccount[] = [
{
id: 'cc1',
name: 'Chase Sapphire Preferred',
categoryId: 'credit-cards',
institution: 'Chase',
accountNumber: '4521',
originalBalance: 8500,
currentBalance: 3200,
interestRate: 21.99,
minimumPayment: 95,
dueDay: 15,
createdAt: '2024-01-15',
updatedAt: '2024-12-01'
},
{
id: 'cc2',
name: 'Amex Blue Cash',
categoryId: 'credit-cards',
institution: 'American Express',
accountNumber: '1008',
originalBalance: 4200,
currentBalance: 1850,
interestRate: 19.24,
minimumPayment: 55,
dueDay: 22,
createdAt: '2024-02-10',
updatedAt: '2024-12-01'
},
{
id: 'cc3',
name: 'Citi Double Cash',
categoryId: 'credit-cards',
institution: 'Citibank',
accountNumber: '7732',
originalBalance: 2800,
currentBalance: 950,
interestRate: 18.49,
minimumPayment: 35,
dueDay: 8,
createdAt: '2024-03-05',
updatedAt: '2024-12-01'
},
{
id: 'al1',
name: 'Tesla Model 3 Loan',
categoryId: 'auto-loans',
institution: 'Tesla Finance',
accountNumber: '9901',
originalBalance: 42000,
currentBalance: 15000,
interestRate: 4.99,
minimumPayment: 650,
dueDay: 1,
createdAt: '2021-06-15',
updatedAt: '2024-12-01'
},
{
id: 'sl1',
name: 'Federal Student Loan',
categoryId: 'student-loans',
institution: 'Dept of Education',
originalBalance: 45000,
currentBalance: 28000,
interestRate: 5.5,
minimumPayment: 320,
dueDay: 25,
createdAt: '2018-09-01',
updatedAt: '2024-12-01'
},
{
id: 'pl1',
name: 'Home Improvement Loan',
categoryId: 'personal-loans',
institution: 'SoFi',
accountNumber: '3344',
originalBalance: 15000,
currentBalance: 8500,
interestRate: 8.99,
minimumPayment: 285,
dueDay: 12,
createdAt: '2023-08-20',
updatedAt: '2024-12-01'
}
];
const mockPayments: DebtPayment[] = [
{id: 'p1', accountId: 'cc1', amount: 500, date: '2024-11-15', note: 'Extra payment'},
{id: 'p2', accountId: 'cc2', amount: 200, date: '2024-11-22'},
{id: 'p3', accountId: 'al1', amount: 650, date: '2024-12-01'},
{id: 'p4', accountId: 'sl1', amount: 320, date: '2024-11-25'}
];
const initialState: DebtsState = {
categories: defaultCategories,
accounts: mockAccounts,
payments: mockPayments,
accounts: [],
payments: [],
isLoading: false,
error: null
};

Some files were not shown because too many files have changed in this diff Show More