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.
This commit is contained in:
2025-12-11 02:11:43 -05:00
parent 4911b5d125
commit 40210c454e
74 changed files with 2599 additions and 1386 deletions

View File

@@ -7,6 +7,7 @@ This backend API is built following **SOLID principles** and **clean architectur
## SOLID Principles Implementation ## SOLID Principles Implementation
### 1. Single Responsibility Principle (SRP) ### 1. Single Responsibility Principle (SRP)
Each class has one well-defined responsibility: Each class has one well-defined responsibility:
- **Controllers** - Handle HTTP requests/responses only - **Controllers** - Handle HTTP requests/responses only
@@ -15,23 +16,25 @@ Each class has one well-defined responsibility:
- **Middleware** - Handle cross-cutting concerns (auth, errors) - **Middleware** - Handle cross-cutting concerns (auth, errors)
Example: Example:
```typescript ```typescript
// AssetService - ONLY handles asset business logic // AssetService - ONLY handles asset business logic
export class AssetService { export class AssetService {
async create(userId: string, data: CreateAssetDTO): Promise<Asset> async create(userId: string, data: CreateAssetDTO): Promise<Asset>;
async update(id: string, userId: string, data: UpdateAssetDTO): Promise<Asset> async update(id: string, userId: string, data: UpdateAssetDTO): Promise<Asset>;
// ... // ...
} }
// AssetRepository - ONLY handles database operations // AssetRepository - ONLY handles database operations
export class AssetRepository { export class AssetRepository {
async findById(id: string): Promise<Asset | null> async findById(id: string): Promise<Asset | null>;
async create(data: Prisma.AssetCreateInput): Promise<Asset> async create(data: Prisma.AssetCreateInput): Promise<Asset>;
// ... // ...
} }
``` ```
### 2. Open/Closed Principle (OCP) ### 2. Open/Closed Principle (OCP)
The system is open for extension but closed for modification: The system is open for extension but closed for modification:
- **Custom Error Classes** - Extend `AppError` base class for new error types - **Custom Error Classes** - Extend `AppError` base class for new error types
@@ -39,6 +42,7 @@ The system is open for extension but closed for modification:
- **Service Pattern** - Add new services without modifying existing ones - **Service Pattern** - Add new services without modifying existing ones
Example: Example:
```typescript ```typescript
// Extensible error hierarchy // Extensible error hierarchy
export abstract class AppError extends Error { export abstract class AppError extends Error {
@@ -55,6 +59,7 @@ export class ValidationError extends AppError {
``` ```
### 3. Liskov Substitution Principle (LSP) ### 3. Liskov Substitution Principle (LSP)
Derived classes can substitute their base classes: Derived classes can substitute their base classes:
```typescript ```typescript
@@ -71,6 +76,7 @@ export interface IUserScopedRepository<T> extends Omit<IRepository<T>, 'findAll'
``` ```
### 4. Interface Segregation Principle (ISP) ### 4. Interface Segregation Principle (ISP)
Clients depend only on interfaces they use: Clients depend only on interfaces they use:
- `IRepository<T>` - Base CRUD operations - `IRepository<T>` - Base CRUD operations
@@ -78,6 +84,7 @@ Clients depend only on interfaces they use:
- Specific methods in services (e.g., `getTotalValue()` in AssetService) - Specific methods in services (e.g., `getTotalValue()` in AssetService)
### 5. Dependency Inversion Principle (DIP) ### 5. Dependency Inversion Principle (DIP)
High-level modules depend on abstractions: High-level modules depend on abstractions:
```typescript ```typescript
@@ -99,6 +106,7 @@ class DatabaseConnection {
## Architecture Layers ## Architecture Layers
### 1. Presentation Layer (Controllers & Routes) ### 1. Presentation Layer (Controllers & Routes)
- **Location**: `src/controllers/`, `src/routes/` - **Location**: `src/controllers/`, `src/routes/`
- **Purpose**: Handle HTTP requests/responses - **Purpose**: Handle HTTP requests/responses
- **Responsibilities**: - **Responsibilities**:
@@ -119,6 +127,7 @@ export class AssetController {
``` ```
### 2. Business Logic Layer (Services) ### 2. Business Logic Layer (Services)
- **Location**: `src/services/` - **Location**: `src/services/`
- **Purpose**: Implement business rules - **Purpose**: Implement business rules
- **Responsibilities**: - **Responsibilities**:
@@ -139,6 +148,7 @@ export class InvoiceService {
``` ```
### 3. Data Access Layer (Repositories) ### 3. Data Access Layer (Repositories)
- **Location**: `src/repositories/` - **Location**: `src/repositories/`
- **Purpose**: Abstract database operations - **Purpose**: Abstract database operations
- **Responsibilities**: - **Responsibilities**:
@@ -151,13 +161,14 @@ export class AssetRepository implements IUserScopedRepository<Asset> {
async findAllByUser(userId: string): Promise<Asset[]> { async findAllByUser(userId: string): Promise<Asset[]> {
return prisma.asset.findMany({ return prisma.asset.findMany({
where: {userId}, where: {userId},
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
} }
``` ```
### 4. Cross-Cutting Concerns (Middleware & Utils) ### 4. Cross-Cutting Concerns (Middleware & Utils)
- **Location**: `src/middleware/`, `src/utils/` - **Location**: `src/middleware/`, `src/utils/`
- **Purpose**: Handle common functionality - **Purpose**: Handle common functionality
- **Components**: - **Components**:
@@ -221,21 +232,25 @@ User
## Security Features ## Security Features
### 1. Authentication ### 1. Authentication
- JWT tokens with configurable expiration - JWT tokens with configurable expiration
- Secure password hashing (bcrypt with 10 rounds) - Secure password hashing (bcrypt with 10 rounds)
- Password complexity requirements - Password complexity requirements
### 2. Authorization ### 2. Authorization
- User-scoped data access - User-scoped data access
- Repository methods verify ownership - Repository methods verify ownership
- Middleware extracts authenticated user - Middleware extracts authenticated user
### 3. Input Validation ### 3. Input Validation
- Zod schemas for runtime validation - Zod schemas for runtime validation
- Type-safe request/response handling - Type-safe request/response handling
- SQL injection prevention (Prisma ORM) - SQL injection prevention (Prisma ORM)
### 4. Error Handling ### 4. Error Handling
- Custom error classes - Custom error classes
- No sensitive information in error messages - No sensitive information in error messages
- Proper HTTP status codes - Proper HTTP status codes
@@ -243,6 +258,7 @@ User
## API Design ## API Design
### RESTful Conventions ### RESTful Conventions
- `GET /api/resources` - List all - `GET /api/resources` - List all
- `GET /api/resources/:id` - Get one - `GET /api/resources/:id` - Get one
- `POST /api/resources` - Create - `POST /api/resources` - Create
@@ -251,11 +267,16 @@ User
- `DELETE /api/resources/:id` - Delete - `DELETE /api/resources/:id` - Delete
### Response Format ### Response Format
```json ```json
{ {
"resource": { /* data */ }, "resource": {
/* data */
},
// or // or
"resources": [ /* array */ ], "resources": [
/* array */
],
// or on error // or on error
"error": "ErrorType", "error": "ErrorType",
"message": "Human-readable message" "message": "Human-readable message"
@@ -263,6 +284,7 @@ User
``` ```
### HTTP Status Codes ### HTTP Status Codes
- `200 OK` - Successful GET/PUT/PATCH - `200 OK` - Successful GET/PUT/PATCH
- `201 Created` - Successful POST - `201 Created` - Successful POST
- `204 No Content` - Successful DELETE - `204 No Content` - Successful DELETE
@@ -276,16 +298,19 @@ User
## Testing Strategy ## Testing Strategy
### Unit Tests ### Unit Tests
- Test services in isolation - Test services in isolation
- Mock repository dependencies - Mock repository dependencies
- Test business logic thoroughly - Test business logic thoroughly
### Integration Tests ### Integration Tests
- Test API endpoints - Test API endpoints
- Use test database - Use test database
- Verify request/response flow - Verify request/response flow
### E2E Tests ### E2E Tests
- Test complete user flows - Test complete user flows
- Verify authentication - Verify authentication
- Test error scenarios - Test error scenarios
@@ -293,16 +318,19 @@ User
## Performance Considerations ## Performance Considerations
### Database ### Database
- Indexes on frequently queried fields - Indexes on frequently queried fields
- Connection pooling (Prisma) - Connection pooling (Prisma)
- Efficient query composition - Efficient query composition
### Caching ### Caching
- JWT tokens cached in client - JWT tokens cached in client
- Consider Redis for session management - Consider Redis for session management
- Database query result caching - Database query result caching
### Scalability ### Scalability
- Stateless API (horizontal scaling) - Stateless API (horizontal scaling)
- Database migrations for schema changes - Database migrations for schema changes
- Environment-based configuration - Environment-based configuration
@@ -328,18 +356,21 @@ User
## Best Practices ## Best Practices
### Code Organization ### Code Organization
✅ One class per file ✅ One class per file
✅ Group related files in directories ✅ Group related files in directories
✅ Use barrel exports (index.ts) ✅ Use barrel exports (index.ts)
✅ Consistent naming conventions ✅ Consistent naming conventions
### Error Handling ### Error Handling
✅ Use custom error classes ✅ Use custom error classes
✅ Validate at boundaries ✅ Validate at boundaries
✅ Log errors appropriately ✅ Log errors appropriately
✅ Return user-friendly messages ✅ Return user-friendly messages
### Security ### Security
✅ Never log sensitive data ✅ Never log sensitive data
✅ Validate all inputs ✅ Validate all inputs
✅ Use parameterized queries (Prisma) ✅ Use parameterized queries (Prisma)
@@ -347,6 +378,7 @@ User
✅ Keep dependencies updated ✅ Keep dependencies updated
### Documentation ### Documentation
✅ JSDoc comments for public APIs ✅ JSDoc comments for public APIs
✅ README for setup instructions ✅ README for setup instructions
✅ API documentation (Swagger) ✅ API documentation (Swagger)

View File

@@ -1,6 +1,6 @@
--- ---
description: Use Bun instead of Node.js, npm, pnpm, or vite. description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" globs: '*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json'
alwaysApply: false alwaysApply: false
--- ---

View File

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

View File

@@ -12,7 +12,7 @@ class DatabaseConnection {
public static getInstance(): PrismaClient { public static getInstance(): PrismaClient {
if (!DatabaseConnection.instance) { if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new PrismaClient({ DatabaseConnection.instance = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
}); });
} }
return DatabaseConnection.instance; return DatabaseConnection.instance;

View File

@@ -10,7 +10,7 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1), DATABASE_URL: z.string().min(1),
JWT_SECRET: z.string().min(32), JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('7d'), JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:5174'), CORS_ORIGIN: z.string().default('http://localhost:5174')
}); });
type EnvConfig = z.infer<typeof envSchema>; type EnvConfig = z.infer<typeof envSchema>;

View File

@@ -9,13 +9,13 @@ const ASSET_TYPES = ['cash', 'investment', 'property', 'vehicle', 'other'] as co
const createAssetSchema = z.object({ const createAssetSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
type: z.enum(ASSET_TYPES), type: z.enum(ASSET_TYPES),
value: z.number().min(0), value: z.number().min(0)
}); });
const updateAssetSchema = z.object({ const updateAssetSchema = z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
type: z.enum(ASSET_TYPES).optional(), type: z.enum(ASSET_TYPES).optional(),
value: z.number().min(0).optional(), value: z.number().min(0).optional()
}); });
/** /**
@@ -32,12 +32,14 @@ export class AssetController {
async getAll(request: FastifyRequest, reply: FastifyReply) { async getAll(request: FastifyRequest, reply: FastifyReply) {
const userId = getUserId(request); const userId = getUserId(request);
const assets = await this.assetService.getAll(userId); const assets = await this.assetService.getAll(userId);
return reply.send({assets}); return reply.send({assets});
} }
async getById(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) { async getById(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) {
const userId = getUserId(request); const userId = getUserId(request);
const asset = await this.assetService.getById(request.params.id, userId); const asset = await this.assetService.getById(request.params.id, userId);
return reply.send({asset}); return reply.send({asset});
} }
@@ -45,6 +47,7 @@ export class AssetController {
const userId = getUserId(request); const userId = getUserId(request);
const data = createAssetSchema.parse(request.body); const data = createAssetSchema.parse(request.body);
const asset = await this.assetService.create(userId, data); const asset = await this.assetService.create(userId, data);
return reply.status(201).send({asset}); return reply.status(201).send({asset});
} }
@@ -52,12 +55,14 @@ export class AssetController {
const userId = getUserId(request); const userId = getUserId(request);
const data = updateAssetSchema.parse(request.body); const data = updateAssetSchema.parse(request.body);
const asset = await this.assetService.update(request.params.id, userId, data); const asset = await this.assetService.update(request.params.id, userId, data);
return reply.send({asset}); return reply.send({asset});
} }
async delete(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) { async delete(request: FastifyRequest<{Params: {id: string}}>, reply: FastifyReply) {
const userId = getUserId(request); const userId = getUserId(request);
await this.assetService.delete(request.params.id, userId); await this.assetService.delete(request.params.id, userId);
return reply.status(204).send(); return reply.status(204).send();
} }
} }

View File

@@ -8,12 +8,12 @@ import {DebtCategoryRepository} from '../repositories/DebtCategoryRepository';
const registerSchema = z.object({ const registerSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8),
name: z.string().min(1), name: z.string().min(1)
}); });
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string(), password: z.string()
}); });
/** /**
@@ -37,12 +37,12 @@ export class AuthController {
const token = request.server.jwt.sign({ const token = request.server.jwt.sign({
id: user.id, id: user.id,
email: user.email, email: user.email
}); });
return reply.status(201).send({ return reply.status(201).send({
user, user,
token, token
}); });
} }
@@ -52,14 +52,14 @@ export class AuthController {
const token = request.server.jwt.sign({ const token = request.server.jwt.sign({
id: user.id, id: user.id,
email: user.email, email: user.email
}); });
const {password: _, ...userWithoutPassword} = user; const {password: _, ...userWithoutPassword} = user;
return reply.send({ return reply.send({
user: userWithoutPassword, user: userWithoutPassword,
token, token
}); });
} }

View File

@@ -7,7 +7,7 @@ const createIncomeSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
amount: z.number().min(0.01), amount: z.number().min(0.01),
frequency: z.string(), frequency: z.string(),
notes: z.string().optional(), notes: z.string().optional()
}); });
const updateIncomeSchema = createIncomeSchema.partial(); const updateIncomeSchema = createIncomeSchema.partial();
@@ -17,8 +17,11 @@ const createExpenseSchema = z.object({
amount: z.number().min(0.01), amount: z.number().min(0.01),
category: z.string(), category: z.string(),
frequency: z.string(), frequency: z.string(),
dueDate: z.string().transform(str => new Date(str)).optional(), dueDate: z
notes: z.string().optional(), .string()
.transform(str => new Date(str))
.optional(),
notes: z.string().optional()
}); });
const updateExpenseSchema = createExpenseSchema.partial(); const updateExpenseSchema = createExpenseSchema.partial();
@@ -29,7 +32,7 @@ const createTransactionSchema = z.object({
amount: z.number().min(0.01), amount: z.number().min(0.01),
date: z.string().transform(str => new Date(str)), date: z.string().transform(str => new Date(str)),
description: z.string().optional(), description: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional()
}); });
/** /**
@@ -151,11 +154,7 @@ export class CashflowController {
} }
if (startDate && endDate) { if (startDate && endDate) {
const transactions = await this.cashflowService.getTransactionsByDateRange( const transactions = await this.cashflowService.getTransactionsByDateRange(userId, new Date(startDate), new Date(endDate));
userId,
new Date(startDate),
new Date(endDate)
);
return reply.send({transactions}); return reply.send({transactions});
} }
@@ -181,11 +180,7 @@ export class CashflowController {
const userId = getUserId(request); const userId = getUserId(request);
const {startDate, endDate} = request.query as {startDate: string; endDate: string}; const {startDate, endDate} = request.query as {startDate: string; endDate: string};
const summary = await this.cashflowService.getCashflowSummary( const summary = await this.cashflowService.getCashflowSummary(userId, new Date(startDate), new Date(endDate));
userId,
new Date(startDate),
new Date(endDate)
);
return reply.send(summary); return reply.send(summary);
} }

View File

@@ -8,7 +8,7 @@ const createClientSchema = z.object({
email: z.string().email(), email: z.string().email(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
address: z.string().optional(), address: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional()
}); });
const updateClientSchema = z.object({ const updateClientSchema = z.object({
@@ -16,7 +16,7 @@ const updateClientSchema = z.object({
email: z.string().email().optional(), email: z.string().email().optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
address: z.string().optional(), address: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional()
}); });
/** /**

View File

@@ -12,8 +12,11 @@ const createAccountSchema = z.object({
currentBalance: z.number().min(0), currentBalance: z.number().min(0),
interestRate: z.number().min(0).max(100).optional(), interestRate: z.number().min(0).max(100).optional(),
minimumPayment: z.number().min(0).optional(), minimumPayment: z.number().min(0).optional(),
dueDate: z.string().transform(str => new Date(str)).optional(), dueDate: z
notes: z.string().optional(), .string()
.transform(str => new Date(str))
.optional(),
notes: z.string().optional()
}); });
const updateAccountSchema = z.object({ const updateAccountSchema = z.object({
@@ -23,8 +26,11 @@ const updateAccountSchema = z.object({
currentBalance: z.number().min(0).optional(), currentBalance: z.number().min(0).optional(),
interestRate: z.number().min(0).max(100).optional(), interestRate: z.number().min(0).max(100).optional(),
minimumPayment: z.number().min(0).optional(), minimumPayment: z.number().min(0).optional(),
dueDate: z.string().transform(str => new Date(str)).optional(), dueDate: z
notes: z.string().optional(), .string()
.transform(str => new Date(str))
.optional(),
notes: z.string().optional()
}); });
/** /**

View File

@@ -6,13 +6,19 @@ import {z} from 'zod';
const createCategorySchema = z.object({ const createCategorySchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
description: z.string().optional(), description: z.string().optional(),
color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(), color: z
.string()
.regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/)
.optional()
}); });
const updateCategorySchema = z.object({ const updateCategorySchema = z.object({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
description: z.string().optional(), description: z.string().optional(),
color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/).optional(), color: z
.string()
.regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/)
.optional()
}); });
/** /**

View File

@@ -7,7 +7,7 @@ const createPaymentSchema = z.object({
accountId: z.string().uuid(), accountId: z.string().uuid(),
amount: z.number().min(0.01), amount: z.number().min(0.01),
paymentDate: z.string().transform(str => new Date(str)), paymentDate: z.string().transform(str => new Date(str)),
notes: z.string().optional(), notes: z.string().optional()
}); });
/** /**
@@ -46,11 +46,7 @@ export class DebtPaymentController {
} }
if (startDate && endDate) { if (startDate && endDate) {
const payments = await this.paymentService.getByDateRange( const payments = await this.paymentService.getByDateRange(userId, new Date(startDate), new Date(endDate));
userId,
new Date(startDate),
new Date(endDate)
);
return reply.send({payments}); return reply.send({payments});
} }

View File

@@ -7,7 +7,7 @@ const lineItemSchema = z.object({
description: z.string().min(1), description: z.string().min(1),
quantity: z.number().min(1), quantity: z.number().min(1),
unitPrice: z.number().min(0), unitPrice: z.number().min(0),
amount: z.number().min(0), amount: z.number().min(0)
}); });
const createInvoiceSchema = z.object({ const createInvoiceSchema = z.object({
@@ -16,19 +16,25 @@ const createInvoiceSchema = z.object({
dueDate: z.string().transform(str => new Date(str)), dueDate: z.string().transform(str => new Date(str)),
lineItems: z.array(lineItemSchema).min(1), lineItems: z.array(lineItemSchema).min(1),
notes: z.string().optional(), notes: z.string().optional(),
terms: z.string().optional(), terms: z.string().optional()
}); });
const updateInvoiceSchema = z.object({ const updateInvoiceSchema = z.object({
issueDate: z.string().transform(str => new Date(str)).optional(), issueDate: z
dueDate: z.string().transform(str => new Date(str)).optional(), .string()
.transform(str => new Date(str))
.optional(),
dueDate: z
.string()
.transform(str => new Date(str))
.optional(),
lineItems: z.array(lineItemSchema).min(1).optional(), lineItems: z.array(lineItemSchema).min(1).optional(),
notes: z.string().optional(), notes: z.string().optional(),
terms: z.string().optional(), terms: z.string().optional()
}); });
const updateStatusSchema = z.object({ const updateStatusSchema = z.object({
status: z.enum(['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED']), status: z.enum(['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED'])
}); });
/** /**
@@ -59,7 +65,7 @@ export class InvoiceController {
const invoices = await this.invoiceService.getAllByUser(userId, { const invoices = await this.invoiceService.getAllByUser(userId, {
clientId, clientId,
status, status
}); });
return reply.send({invoices}); return reply.send({invoices});

View File

@@ -9,9 +9,12 @@ const createLiabilitySchema = z.object({
currentBalance: z.number().min(0), currentBalance: z.number().min(0),
interestRate: z.number().min(0).max(100).optional(), interestRate: z.number().min(0).max(100).optional(),
minimumPayment: z.number().min(0).optional(), minimumPayment: z.number().min(0).optional(),
dueDate: z.string().transform(str => new Date(str)).optional(), dueDate: z
.string()
.transform(str => new Date(str))
.optional(),
creditor: z.string().max(255).optional(), creditor: z.string().max(255).optional(),
notes: z.string().optional(), notes: z.string().optional()
}); });
const updateLiabilitySchema = z.object({ const updateLiabilitySchema = z.object({
@@ -20,9 +23,12 @@ const updateLiabilitySchema = z.object({
currentBalance: z.number().min(0).optional(), currentBalance: z.number().min(0).optional(),
interestRate: z.number().min(0).max(100).optional(), interestRate: z.number().min(0).max(100).optional(),
minimumPayment: z.number().min(0).optional(), minimumPayment: z.number().min(0).optional(),
dueDate: z.string().transform(str => new Date(str)).optional(), dueDate: z
.string()
.transform(str => new Date(str))
.optional(),
creditor: z.string().max(255).optional(), creditor: z.string().max(255).optional(),
notes: z.string().optional(), notes: z.string().optional()
}); });
/** /**

View File

@@ -8,16 +8,16 @@ const createSnapshotSchema = z.object({
totalAssets: z.number().min(0), totalAssets: z.number().min(0),
totalLiabilities: z.number().min(0), totalLiabilities: z.number().min(0),
netWorth: z.number(), netWorth: z.number(),
notes: z.string().optional(), notes: z.string().optional()
}); });
const createFromCurrentSchema = z.object({ const createFromCurrentSchema = z.object({
notes: z.string().optional(), notes: z.string().optional()
}); });
const dateRangeSchema = z.object({ const dateRangeSchema = z.object({
startDate: z.string().transform(str => new Date(str)), startDate: z.string().transform(str => new Date(str)),
endDate: z.string().transform(str => new Date(str)), endDate: z.string().transform(str => new Date(str))
}); });
/** /**
@@ -55,11 +55,7 @@ export class NetWorthController {
const {startDate, endDate} = request.query as {startDate: string; endDate: string}; const {startDate, endDate} = request.query as {startDate: string; endDate: string};
const parsed = dateRangeSchema.parse({startDate, endDate}); const parsed = dateRangeSchema.parse({startDate, endDate});
const snapshots = await this.netWorthService.getSnapshotsByDateRange( const snapshots = await this.netWorthService.getSnapshotsByDateRange(userId, parsed.startDate, parsed.endDate);
userId,
parsed.startDate,
parsed.endDate
);
return reply.send({snapshots}); return reply.send({snapshots});
} }
@@ -119,10 +115,7 @@ export class NetWorthController {
const userId = getUserId(request); const userId = getUserId(request);
const {limit} = request.query as {limit?: string}; const {limit} = request.query as {limit?: string};
const stats = await this.netWorthService.getGrowthStats( const stats = await this.netWorthService.getGrowthStats(userId, limit ? parseInt(limit) : undefined);
userId,
limit ? parseInt(limit) : undefined
);
return reply.send({stats}); return reply.send({stats});
} }

View File

@@ -12,7 +12,7 @@ async function main() {
// Start server // Start server
await server.listen({ await server.listen({
port: env.PORT, port: env.PORT,
host: '0.0.0.0', host: '0.0.0.0'
}); });
server.log.info(`🚀 Server listening on http://localhost:${env.PORT}`); server.log.info(`🚀 Server listening on http://localhost:${env.PORT}`);

View File

@@ -14,7 +14,7 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest,
if (error instanceof AppError) { if (error instanceof AppError) {
return reply.status(error.statusCode).send({ return reply.status(error.statusCode).send({
error: error.name, error: error.name,
message: error.message, message: error.message
}); });
} }
@@ -23,7 +23,7 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest,
return reply.status(400).send({ return reply.status(400).send({
error: 'ValidationError', error: 'ValidationError',
message: 'Invalid request data', message: 'Invalid request data',
details: error.errors, details: error.errors
}); });
} }
@@ -32,7 +32,7 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest,
return reply.status(400).send({ return reply.status(400).send({
error: 'ValidationError', error: 'ValidationError',
message: error.message, message: error.message,
details: error.validation, details: error.validation
}); });
} }
@@ -42,13 +42,13 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest,
if (prismaError.code === 'P2002') { if (prismaError.code === 'P2002') {
return reply.status(409).send({ return reply.status(409).send({
error: 'ConflictError', error: 'ConflictError',
message: 'A record with this value already exists', message: 'A record with this value already exists'
}); });
} }
if (prismaError.code === 'P2025') { if (prismaError.code === 'P2025') {
return reply.status(404).send({ return reply.status(404).send({
error: 'NotFoundError', error: 'NotFoundError',
message: 'Record not found', message: 'Record not found'
}); });
} }
} }
@@ -59,6 +59,6 @@ export async function errorHandler(error: FastifyError, request: FastifyRequest,
return reply.status(statusCode).send({ return reply.status(statusCode).send({
error: 'ServerError', error: 'ServerError',
message, message
}); });
} }

View File

@@ -12,14 +12,14 @@ export class AssetRepository {
async findByIdAndUser(id: string, userId: string): Promise<Asset | null> { async findByIdAndUser(id: string, userId: string): Promise<Asset | null> {
return prisma.asset.findFirst({ return prisma.asset.findFirst({
where: {id, userId}, where: {id, userId}
}); });
} }
async findAllByUser(userId: string, filters?: Record<string, any>): Promise<Asset[]> { async findAllByUser(userId: string, filters?: Record<string, any>): Promise<Asset[]> {
return prisma.asset.findMany({ return prisma.asset.findMany({
where: {userId, ...filters}, where: {userId, ...filters},
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
@@ -30,7 +30,7 @@ export class AssetRepository {
async update(id: string, data: Prisma.AssetUpdateInput): Promise<Asset> { async update(id: string, data: Prisma.AssetUpdateInput): Promise<Asset> {
return prisma.asset.update({ return prisma.asset.update({
where: {id}, where: {id},
data, data
}); });
} }
@@ -41,7 +41,7 @@ export class AssetRepository {
async getTotalValue(userId: string): Promise<number> { async getTotalValue(userId: string): Promise<number> {
const result = await prisma.asset.aggregate({ const result = await prisma.asset.aggregate({
where: {userId}, where: {userId},
_sum: {value: true}, _sum: {value: true}
}); });
return result._sum.value || 0; return result._sum.value || 0;
} }

View File

@@ -18,7 +18,7 @@ export class IncomeSourceRepository {
async findAllByUser(userId: string): Promise<IncomeSource[]> { async findAllByUser(userId: string): Promise<IncomeSource[]> {
return prisma.incomeSource.findMany({ return prisma.incomeSource.findMany({
where: {userId}, where: {userId},
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
@@ -37,7 +37,7 @@ export class IncomeSourceRepository {
async getTotalMonthlyIncome(userId: string): Promise<number> { async getTotalMonthlyIncome(userId: string): Promise<number> {
const result = await prisma.incomeSource.aggregate({ const result = await prisma.incomeSource.aggregate({
where: {userId}, where: {userId},
_sum: {amount: true}, _sum: {amount: true}
}); });
return result._sum.amount || 0; return result._sum.amount || 0;
} }
@@ -58,7 +58,7 @@ export class ExpenseRepository {
async findAllByUser(userId: string): Promise<Expense[]> { async findAllByUser(userId: string): Promise<Expense[]> {
return prisma.expense.findMany({ return prisma.expense.findMany({
where: {userId}, where: {userId},
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
@@ -77,18 +77,21 @@ export class ExpenseRepository {
async getTotalMonthlyExpenses(userId: string): Promise<number> { async getTotalMonthlyExpenses(userId: string): Promise<number> {
const result = await prisma.expense.aggregate({ const result = await prisma.expense.aggregate({
where: {userId}, where: {userId},
_sum: {amount: true}, _sum: {amount: true}
}); });
return result._sum.amount || 0; return result._sum.amount || 0;
} }
async getByCategory(userId: string): Promise<Record<string, Expense[]>> { async getByCategory(userId: string): Promise<Record<string, Expense[]>> {
const expenses = await this.findAllByUser(userId); const expenses = await this.findAllByUser(userId);
return expenses.reduce((acc, expense) => { return expenses.reduce(
if (!acc[expense.category]) acc[expense.category] = []; (acc, expense) => {
acc[expense.category].push(expense); if (!acc[expense.category]) acc[expense.category] = [];
return acc; acc[expense.category].push(expense);
}, {} as Record<string, Expense[]>); return acc;
},
{} as Record<string, Expense[]>
);
} }
} }
@@ -107,7 +110,7 @@ export class TransactionRepository {
async findAllByUser(userId: string): Promise<Transaction[]> { async findAllByUser(userId: string): Promise<Transaction[]> {
return prisma.transaction.findMany({ return prisma.transaction.findMany({
where: {userId}, where: {userId},
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
@@ -127,38 +130,38 @@ export class TransactionRepository {
return prisma.transaction.findMany({ return prisma.transaction.findMany({
where: { where: {
userId, userId,
date: {gte: startDate, lte: endDate}, date: {gte: startDate, lte: endDate}
}, },
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
async getByType(userId: string, type: string): Promise<Transaction[]> { async getByType(userId: string, type: string): Promise<Transaction[]> {
return prisma.transaction.findMany({ return prisma.transaction.findMany({
where: {userId, type}, where: {userId, type},
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
async getCashflowSummary(userId: string, startDate: Date, endDate: Date): Promise<{ async getCashflowSummary(
userId: string,
startDate: Date,
endDate: Date
): Promise<{
totalIncome: number; totalIncome: number;
totalExpenses: number; totalExpenses: number;
netCashflow: number; netCashflow: number;
}> { }> {
const transactions = await this.getByDateRange(userId, startDate, endDate); const transactions = await this.getByDateRange(userId, startDate, endDate);
const totalIncome = transactions const totalIncome = transactions.filter(t => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + t.amount, 0);
const totalExpenses = transactions const totalExpenses = transactions.filter(t => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + t.amount, 0);
return { return {
totalIncome, totalIncome,
totalExpenses, totalExpenses,
netCashflow: totalIncome - totalExpenses, netCashflow: totalIncome - totalExpenses
}; };
} }
} }

View File

@@ -12,8 +12,8 @@ export class ClientRepository {
return prisma.client.findUnique({ return prisma.client.findUnique({
where: {id}, where: {id},
include: { include: {
invoices: true, invoices: true
}, }
}); });
} }
@@ -21,8 +21,8 @@ export class ClientRepository {
return prisma.client.findFirst({ return prisma.client.findFirst({
where: {id, userId}, where: {id, userId},
include: { include: {
invoices: true, invoices: true
}, }
}); });
} }
@@ -31,10 +31,10 @@ export class ClientRepository {
where: {userId}, where: {userId},
include: { include: {
invoices: { invoices: {
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}, }
}, },
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
@@ -42,8 +42,8 @@ export class ClientRepository {
return prisma.client.create({ return prisma.client.create({
data, data,
include: { include: {
invoices: true, invoices: true
}, }
}); });
} }
@@ -52,14 +52,14 @@ export class ClientRepository {
where: {id}, where: {id},
data, data,
include: { include: {
invoices: true, invoices: true
}, }
}); });
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await prisma.client.delete({ await prisma.client.delete({
where: {id}, where: {id}
}); });
} }
@@ -70,8 +70,8 @@ export class ClientRepository {
return prisma.client.findFirst({ return prisma.client.findFirst({
where: { where: {
userId, userId,
email, email
}, }
}); });
} }
@@ -82,13 +82,13 @@ export class ClientRepository {
const result = await prisma.invoice.aggregate({ const result = await prisma.invoice.aggregate({
where: { where: {
client: { client: {
userId, userId
}, },
status: 'paid', status: 'paid'
}, },
_sum: { _sum: {
total: true, total: true
}, }
}); });
return result._sum.total || 0; return result._sum.total || 0;
@@ -105,11 +105,11 @@ export class ClientRepository {
select: { select: {
id: true, id: true,
total: true, total: true,
status: true, status: true
}, }
}, }
}, },
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
return clients.map(client => ({ return clients.map(client => ({
@@ -117,13 +117,9 @@ export class ClientRepository {
stats: { stats: {
totalInvoices: client.invoices.length, totalInvoices: client.invoices.length,
paidInvoices: client.invoices.filter(inv => inv.status === 'paid').length, paidInvoices: client.invoices.filter(inv => inv.status === 'paid').length,
totalRevenue: client.invoices totalRevenue: client.invoices.filter(inv => inv.status === 'paid').reduce((sum, inv) => sum + inv.total, 0),
.filter(inv => inv.status === 'paid') outstandingAmount: client.invoices.filter(inv => inv.status !== 'paid').reduce((sum, inv) => sum + inv.total, 0)
.reduce((sum, inv) => sum + inv.total, 0), }
outstandingAmount: client.invoices
.filter(inv => inv.status !== 'paid')
.reduce((sum, inv) => sum + inv.total, 0),
},
})); }));
} }
} }

View File

@@ -14,9 +14,9 @@ export class DebtAccountRepository {
include: { include: {
category: true, category: true,
payments: { payments: {
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}, }
}, }
}); });
} }
@@ -26,9 +26,9 @@ export class DebtAccountRepository {
include: { include: {
category: true, category: true,
payments: { payments: {
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}, }
}, }
}); });
} }
@@ -38,10 +38,10 @@ export class DebtAccountRepository {
include: { include: {
category: true, category: true,
payments: { payments: {
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}, }
}, },
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
@@ -50,10 +50,10 @@ export class DebtAccountRepository {
where: {categoryId}, where: {categoryId},
include: { include: {
payments: { payments: {
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}, }
}, },
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
@@ -62,8 +62,8 @@ export class DebtAccountRepository {
data, data,
include: { include: {
category: true, category: true,
payments: true, payments: true
}, }
}); });
} }
@@ -73,14 +73,14 @@ export class DebtAccountRepository {
data, data,
include: { include: {
category: true, category: true,
payments: true, payments: true
}, }
}); });
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await prisma.debtAccount.delete({ await prisma.debtAccount.delete({
where: {id}, where: {id}
}); });
} }
@@ -91,8 +91,8 @@ export class DebtAccountRepository {
const result = await prisma.debtAccount.aggregate({ const result = await prisma.debtAccount.aggregate({
where: {userId}, where: {userId},
_sum: { _sum: {
currentBalance: true, currentBalance: true
}, }
}); });
return result._sum.currentBalance || 0; return result._sum.currentBalance || 0;
@@ -107,9 +107,9 @@ export class DebtAccountRepository {
include: { include: {
category: true, category: true,
payments: { payments: {
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}, }
}, }
}); });
return accounts.map(account => { return accounts.map(account => {
@@ -122,8 +122,8 @@ export class DebtAccountRepository {
totalPaid, totalPaid,
numberOfPayments: account.payments.length, numberOfPayments: account.payments.length,
lastPaymentDate: lastPayment?.date || null, lastPaymentDate: lastPayment?.date || null,
lastPaymentAmount: lastPayment?.amount || null, lastPaymentAmount: lastPayment?.amount || null
}, }
}; };
}); });
} }

View File

@@ -15,10 +15,10 @@ export class DebtCategoryRepository implements IUserScopedRepository<DebtCategor
include: { include: {
accounts: { accounts: {
include: { include: {
payments: true, payments: true
}, }
}, }
}, }
}); });
} }
@@ -28,12 +28,12 @@ export class DebtCategoryRepository implements IUserScopedRepository<DebtCategor
include: { include: {
accounts: { accounts: {
include: { include: {
payments: true, payments: true
}, },
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}, }
}, },
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
@@ -41,8 +41,8 @@ export class DebtCategoryRepository implements IUserScopedRepository<DebtCategor
return prisma.debtCategory.create({ return prisma.debtCategory.create({
data, data,
include: { include: {
accounts: true, accounts: true
}, }
}); });
} }
@@ -51,14 +51,14 @@ export class DebtCategoryRepository implements IUserScopedRepository<DebtCategor
where: {id}, where: {id},
data, data,
include: { include: {
accounts: true, accounts: true
}, }
}); });
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await prisma.debtCategory.delete({ await prisma.debtCategory.delete({
where: {id}, where: {id}
}); });
} }
@@ -69,8 +69,8 @@ export class DebtCategoryRepository implements IUserScopedRepository<DebtCategor
return prisma.debtCategory.findFirst({ return prisma.debtCategory.findFirst({
where: { where: {
userId, userId,
name, name
}, }
}); });
} }
@@ -81,8 +81,8 @@ export class DebtCategoryRepository implements IUserScopedRepository<DebtCategor
const result = await prisma.debtAccount.aggregate({ const result = await prisma.debtAccount.aggregate({
where: {categoryId}, where: {categoryId},
_sum: { _sum: {
currentBalance: true, currentBalance: true
}, }
}); });
return result._sum.currentBalance || 0; return result._sum.currentBalance || 0;
@@ -97,19 +97,15 @@ export class DebtCategoryRepository implements IUserScopedRepository<DebtCategor
return Promise.all( return Promise.all(
categories.map(async category => { categories.map(async category => {
const totalDebt = await this.getTotalDebt(category.id); const totalDebt = await this.getTotalDebt(category.id);
const totalPayments = category.accounts.reduce( const totalPayments = category.accounts.reduce((sum, account) => sum + account.payments.reduce((pSum, payment) => pSum + payment.amount, 0), 0);
(sum, account) =>
sum + account.payments.reduce((pSum, payment) => pSum + payment.amount, 0),
0
);
return { return {
...category, ...category,
stats: { stats: {
totalAccounts: category.accounts.length, totalAccounts: category.accounts.length,
totalDebt, totalDebt,
totalPayments, totalPayments
}, }
}; };
}) })
); );

View File

@@ -14,17 +14,17 @@ export class DebtPaymentRepository {
include: { include: {
account: { account: {
include: { include: {
category: true, category: true
}, }
}, }
}, }
}); });
} }
async findByAccount(accountId: string): Promise<DebtPayment[]> { async findByAccount(accountId: string): Promise<DebtPayment[]> {
return prisma.debtPayment.findMany({ return prisma.debtPayment.findMany({
where: {accountId}, where: {accountId},
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
@@ -33,18 +33,18 @@ export class DebtPaymentRepository {
where: { where: {
account: { account: {
category: { category: {
userId, userId
}, }
}, }
}, },
include: { include: {
account: { account: {
include: { include: {
category: true, category: true
}, }
}, }
}, },
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
@@ -54,16 +54,16 @@ export class DebtPaymentRepository {
include: { include: {
account: { account: {
include: { include: {
category: true, category: true
}, }
}, }
}, }
}); });
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await prisma.debtPayment.delete({ await prisma.debtPayment.delete({
where: {id}, where: {id}
}); });
} }
@@ -74,8 +74,8 @@ export class DebtPaymentRepository {
const result = await prisma.debtPayment.aggregate({ const result = await prisma.debtPayment.aggregate({
where: {accountId}, where: {accountId},
_sum: { _sum: {
amount: true, amount: true
}, }
}); });
return result._sum.amount || 0; return result._sum.amount || 0;
@@ -89,13 +89,13 @@ export class DebtPaymentRepository {
where: { where: {
account: { account: {
category: { category: {
userId, userId
}, }
}, }
}, },
_sum: { _sum: {
amount: true, amount: true
}, }
}); });
return result._sum.amount || 0; return result._sum.amount || 0;
@@ -109,22 +109,22 @@ export class DebtPaymentRepository {
where: { where: {
account: { account: {
category: { category: {
userId, userId
}, }
}, },
date: { date: {
gte: startDate, gte: startDate,
lte: endDate, lte: endDate
}, }
}, },
include: { include: {
account: { account: {
include: { include: {
category: true, category: true
}, }
}, }
}, },
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
} }

View File

@@ -15,14 +15,14 @@ export class InvoiceRepository implements IUserScopedRepository<Invoice> {
async findById(id: string): Promise<Invoice | null> { async findById(id: string): Promise<Invoice | null> {
return prisma.invoice.findUnique({ return prisma.invoice.findUnique({
where: {id}, where: {id},
include: {lineItems: true, client: true}, include: {lineItems: true, client: true}
}) as unknown as Invoice; }) as unknown as Invoice;
} }
async findByIdAndUser(id: string, userId: string): Promise<InvoiceWithLineItems | null> { async findByIdAndUser(id: string, userId: string): Promise<InvoiceWithLineItems | null> {
return prisma.invoice.findFirst({ return prisma.invoice.findFirst({
where: {id, userId}, where: {id, userId},
include: {lineItems: true, client: true}, include: {lineItems: true, client: true}
}); });
} }
@@ -30,14 +30,14 @@ export class InvoiceRepository implements IUserScopedRepository<Invoice> {
return prisma.invoice.findMany({ return prisma.invoice.findMany({
where: {userId, ...filters}, where: {userId, ...filters},
include: {lineItems: true, client: true}, include: {lineItems: true, client: true},
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
async create(data: Prisma.InvoiceCreateInput): Promise<Invoice> { async create(data: Prisma.InvoiceCreateInput): Promise<Invoice> {
return prisma.invoice.create({ return prisma.invoice.create({
data, data,
include: {lineItems: true, client: true}, include: {lineItems: true, client: true}
}) as unknown as Invoice; }) as unknown as Invoice;
} }
@@ -45,7 +45,7 @@ export class InvoiceRepository implements IUserScopedRepository<Invoice> {
return prisma.invoice.update({ return prisma.invoice.update({
where: {id}, where: {id},
data, data,
include: {lineItems: true, client: true}, include: {lineItems: true, client: true}
}) as unknown as Invoice; }) as unknown as Invoice;
} }
@@ -58,8 +58,8 @@ export class InvoiceRepository implements IUserScopedRepository<Invoice> {
where: { where: {
userId, userId,
invoiceNumber, invoiceNumber,
...(excludeId && {id: {not: excludeId}}), ...(excludeId && {id: {not: excludeId}})
}, }
}); });
return count > 0; return count > 0;
} }
@@ -69,8 +69,8 @@ export class InvoiceRepository implements IUserScopedRepository<Invoice> {
const count = await prisma.invoice.count({ const count = await prisma.invoice.count({
where: { where: {
userId, userId,
invoiceNumber: {startsWith: `INV-${year}-`}, invoiceNumber: {startsWith: `INV-${year}-`}
}, }
}); });
return `INV-${year}-${String(count + 1).padStart(3, '0')}`; return `INV-${year}-${String(count + 1).padStart(3, '0')}`;
} }

View File

@@ -10,39 +10,39 @@ const prisma = DatabaseConnection.getInstance();
export class LiabilityRepository { export class LiabilityRepository {
async findById(id: string): Promise<Liability | null> { async findById(id: string): Promise<Liability | null> {
return prisma.liability.findUnique({ return prisma.liability.findUnique({
where: {id}, where: {id}
}); });
} }
async findByIdAndUser(id: string, userId: string): Promise<Liability | null> { async findByIdAndUser(id: string, userId: string): Promise<Liability | null> {
return prisma.liability.findFirst({ return prisma.liability.findFirst({
where: {id, userId}, where: {id, userId}
}); });
} }
async findAllByUser(userId: string): Promise<Liability[]> { async findAllByUser(userId: string): Promise<Liability[]> {
return prisma.liability.findMany({ return prisma.liability.findMany({
where: {userId}, where: {userId},
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'}
}); });
} }
async create(data: Prisma.LiabilityCreateInput): Promise<Liability> { async create(data: Prisma.LiabilityCreateInput): Promise<Liability> {
return prisma.liability.create({ return prisma.liability.create({
data, data
}); });
} }
async update(id: string, data: Prisma.LiabilityUpdateInput): Promise<Liability> { async update(id: string, data: Prisma.LiabilityUpdateInput): Promise<Liability> {
return prisma.liability.update({ return prisma.liability.update({
where: {id}, where: {id},
data, data
}); });
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await prisma.liability.delete({ await prisma.liability.delete({
where: {id}, where: {id}
}); });
} }
@@ -53,8 +53,8 @@ export class LiabilityRepository {
const result = await prisma.liability.aggregate({ const result = await prisma.liability.aggregate({
where: {userId}, where: {userId},
_sum: { _sum: {
balance: true, balance: true
}, }
}); });
return result._sum.balance || 0; return result._sum.balance || 0;
@@ -66,13 +66,16 @@ export class LiabilityRepository {
async getByType(userId: string): Promise<Record<string, Liability[]>> { async getByType(userId: string): Promise<Record<string, Liability[]>> {
const liabilities = await this.findAllByUser(userId); const liabilities = await this.findAllByUser(userId);
return liabilities.reduce((acc, liability) => { return liabilities.reduce(
const type = liability.type; (acc, liability) => {
if (!acc[type]) { const type = liability.type;
acc[type] = []; if (!acc[type]) {
} acc[type] = [];
acc[type].push(liability); }
return acc; acc[type].push(liability);
}, {} as Record<string, Liability[]>); return acc;
},
{} as Record<string, Liability[]>
);
} }
} }

View File

@@ -11,33 +11,33 @@ const prisma = DatabaseConnection.getInstance();
export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWorthSnapshot> { export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWorthSnapshot> {
async findById(id: string): Promise<NetWorthSnapshot | null> { async findById(id: string): Promise<NetWorthSnapshot | null> {
return prisma.netWorthSnapshot.findUnique({ return prisma.netWorthSnapshot.findUnique({
where: {id}, where: {id}
}); });
} }
async findAllByUser(userId: string): Promise<NetWorthSnapshot[]> { async findAllByUser(userId: string): Promise<NetWorthSnapshot[]> {
return prisma.netWorthSnapshot.findMany({ return prisma.netWorthSnapshot.findMany({
where: {userId}, where: {userId},
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
async create(data: Prisma.NetWorthSnapshotCreateInput): Promise<NetWorthSnapshot> { async create(data: Prisma.NetWorthSnapshotCreateInput): Promise<NetWorthSnapshot> {
return prisma.netWorthSnapshot.create({ return prisma.netWorthSnapshot.create({
data, data
}); });
} }
async update(id: string, data: Prisma.NetWorthSnapshotUpdateInput): Promise<NetWorthSnapshot> { async update(id: string, data: Prisma.NetWorthSnapshotUpdateInput): Promise<NetWorthSnapshot> {
return prisma.netWorthSnapshot.update({ return prisma.netWorthSnapshot.update({
where: {id}, where: {id},
data, data
}); });
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await prisma.netWorthSnapshot.delete({ await prisma.netWorthSnapshot.delete({
where: {id}, where: {id}
}); });
} }
@@ -47,7 +47,7 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
async getLatest(userId: string): Promise<NetWorthSnapshot | null> { async getLatest(userId: string): Promise<NetWorthSnapshot | null> {
return prisma.netWorthSnapshot.findFirst({ return prisma.netWorthSnapshot.findFirst({
where: {userId}, where: {userId},
orderBy: {date: 'desc'}, orderBy: {date: 'desc'}
}); });
} }
@@ -60,10 +60,10 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
userId, userId,
date: { date: {
gte: startDate, gte: startDate,
lte: endDate, lte: endDate
}, }
}, },
orderBy: {date: 'asc'}, orderBy: {date: 'asc'}
}); });
} }
@@ -74,8 +74,8 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
const count = await prisma.netWorthSnapshot.count({ const count = await prisma.netWorthSnapshot.count({
where: { where: {
userId, userId,
date, date
}, }
}); });
return count > 0; return count > 0;
@@ -88,7 +88,7 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
const snapshots = await prisma.netWorthSnapshot.findMany({ const snapshots = await prisma.netWorthSnapshot.findMany({
where: {userId}, where: {userId},
orderBy: {date: 'desc'}, orderBy: {date: 'desc'},
take: limit, take: limit
}); });
const stats = []; const stats = [];
@@ -96,14 +96,13 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
const current = snapshots[i]; const current = snapshots[i];
const previous = snapshots[i + 1]; const previous = snapshots[i + 1];
const growthAmount = current.netWorth - previous.netWorth; const growthAmount = current.netWorth - previous.netWorth;
const growthPercent = const growthPercent = previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0;
previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0;
stats.push({ stats.push({
date: current.date, date: current.date,
netWorth: current.netWorth, netWorth: current.netWorth,
growthAmount, growthAmount,
growthPercent: parseFloat(growthPercent.toFixed(2)), growthPercent: parseFloat(growthPercent.toFixed(2))
}); });
} }

View File

@@ -24,8 +24,8 @@ export class UserRepository implements IRepository<User> {
name: true, name: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
password: false, // Never return password password: false // Never return password
}, }
}) as unknown as User[]; }) as unknown as User[];
} }
@@ -36,7 +36,7 @@ export class UserRepository implements IRepository<User> {
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> { async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
return prisma.user.update({ return prisma.user.update({
where: {id}, where: {id},
data, data
}); });
} }

View File

@@ -15,8 +15,7 @@ export interface IRepository<T, CreateInput = unknown, UpdateInput = unknown> {
* User-scoped repository interface * User-scoped repository interface
* For entities that belong to a specific user * For entities that belong to a specific user
*/ */
export interface IUserScopedRepository<T, CreateInput = unknown, UpdateInput = unknown> export interface IUserScopedRepository<T, CreateInput = unknown, UpdateInput = unknown> extends Omit<IRepository<T, CreateInput, UpdateInput>, 'findAll'> {
extends Omit<IRepository<T, CreateInput, UpdateInput>, 'findAll'> {
findAllByUser(userId: string, filters?: Record<string, unknown>): Promise<T[]>; findAllByUser(userId: string, filters?: Record<string, unknown>): Promise<T[]>;
findByIdAndUser(id: string, userId: string): Promise<T | null>; findByIdAndUser(id: string, userId: string): Promise<T | null>;
} }

View File

@@ -16,9 +16,9 @@ export async function assetRoutes(fastify: FastifyInstance) {
schema: { schema: {
tags: ['Assets'], tags: ['Assets'],
description: 'Get all user assets', description: 'Get all user assets',
security: [{bearerAuth: []}], security: [{bearerAuth: []}]
}, },
handler: controller.getAll.bind(controller), handler: controller.getAll.bind(controller)
}); });
fastify.get('/:id', { fastify.get('/:id', {
@@ -29,11 +29,11 @@ export async function assetRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string', format: 'uuid'}, id: {type: 'string', format: 'uuid'}
}, }
}, }
}, },
handler: controller.getById.bind(controller), handler: controller.getById.bind(controller)
}); });
fastify.post('/', { fastify.post('/', {
@@ -47,11 +47,11 @@ export async function assetRoutes(fastify: FastifyInstance) {
properties: { properties: {
name: {type: 'string'}, name: {type: 'string'},
type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']}, type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']},
value: {type: 'number', minimum: 0}, value: {type: 'number', minimum: 0}
}, }
}, }
}, },
handler: controller.create.bind(controller), handler: controller.create.bind(controller)
}); });
fastify.put('/:id', { fastify.put('/:id', {
@@ -62,19 +62,19 @@ export async function assetRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string', format: 'uuid'}, id: {type: 'string', format: 'uuid'}
}, }
}, },
body: { body: {
type: 'object', type: 'object',
properties: { properties: {
name: {type: 'string'}, name: {type: 'string'},
type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']}, type: {type: 'string', enum: ['CASH', 'INVESTMENT', 'PROPERTY', 'VEHICLE', 'OTHER']},
value: {type: 'number', minimum: 0}, value: {type: 'number', minimum: 0}
}, }
}, }
}, },
handler: controller.update.bind(controller), handler: controller.update.bind(controller)
}); });
fastify.delete('/:id', { fastify.delete('/:id', {
@@ -85,10 +85,10 @@ export async function assetRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string', format: 'uuid'}, id: {type: 'string', format: 'uuid'}
}, }
}, }
}, },
handler: controller.delete.bind(controller), handler: controller.delete.bind(controller)
}); });
} }

View File

@@ -18,11 +18,11 @@ export async function authRoutes(fastify: FastifyInstance) {
properties: { properties: {
email: {type: 'string', format: 'email'}, email: {type: 'string', format: 'email'},
password: {type: 'string', minLength: 8}, password: {type: 'string', minLength: 8},
name: {type: 'string', minLength: 1}, name: {type: 'string', minLength: 1}
}, }
}, }
}, },
handler: controller.register.bind(controller), handler: controller.register.bind(controller)
}); });
fastify.post('/login', { fastify.post('/login', {
@@ -34,20 +34,20 @@ export async function authRoutes(fastify: FastifyInstance) {
required: ['email', 'password'], required: ['email', 'password'],
properties: { properties: {
email: {type: 'string', format: 'email'}, email: {type: 'string', format: 'email'},
password: {type: 'string'}, password: {type: 'string'}
}, }
}, }
}, },
handler: controller.login.bind(controller), handler: controller.login.bind(controller)
}); });
fastify.get('/profile', { fastify.get('/profile', {
schema: { schema: {
tags: ['Authentication'], tags: ['Authentication'],
description: 'Get current user profile', description: 'Get current user profile',
security: [{bearerAuth: []}], security: [{bearerAuth: []}]
}, },
preHandler: authenticate, preHandler: authenticate,
handler: controller.getProfile.bind(controller), handler: controller.getProfile.bind(controller)
}); });
} }

View File

@@ -1,11 +1,7 @@
import {FastifyInstance} from 'fastify'; import {FastifyInstance} from 'fastify';
import {CashflowController} from '../controllers/CashflowController'; import {CashflowController} from '../controllers/CashflowController';
import {CashflowService} from '../services/CashflowService'; import {CashflowService} from '../services/CashflowService';
import { import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository';
IncomeSourceRepository,
ExpenseRepository,
TransactionRepository,
} from '../repositories/CashflowRepository';
import {authenticate} from '../middleware/auth'; import {authenticate} from '../middleware/auth';
const incomeRepository = new IncomeSourceRepository(); const incomeRepository = new IncomeSourceRepository();
@@ -19,199 +15,267 @@ export async function cashflowRoutes(fastify: FastifyInstance) {
// ===== Income Source Routes ===== // ===== Income Source Routes =====
fastify.get('/income', { fastify.get(
schema: { '/income',
description: 'Get all income sources', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get all income sources',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.getAllIncome.bind(cashflowController)); cashflowController.getAllIncome.bind(cashflowController)
);
fastify.get('/income/total', { fastify.get(
schema: { '/income/total',
description: 'Get total monthly income', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get total monthly income',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.getTotalMonthlyIncome.bind(cashflowController)); cashflowController.getTotalMonthlyIncome.bind(cashflowController)
);
fastify.get('/income/:id', { fastify.get(
schema: { '/income/:id',
description: 'Get income source by ID', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get income source by ID',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.getOneIncome.bind(cashflowController)); cashflowController.getOneIncome.bind(cashflowController)
);
fastify.post('/income', { fastify.post(
schema: { '/income',
description: 'Create income source', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Create income source',
body: { tags: ['Cashflow'],
type: 'object', security: [{bearerAuth: []}],
required: ['name', 'amount', 'frequency'], body: {
properties: { type: 'object',
name: {type: 'string'}, required: ['name', 'amount', 'frequency'],
amount: {type: 'number'}, properties: {
frequency: {type: 'string'}, name: {type: 'string'},
notes: {type: 'string'}, amount: {type: 'number'},
}, frequency: {type: 'string'},
}, notes: {type: 'string'}
}
}
}
}, },
}, cashflowController.createIncome.bind(cashflowController)); cashflowController.createIncome.bind(cashflowController)
);
fastify.put('/income/:id', { fastify.put(
schema: { '/income/:id',
description: 'Update income source', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Update income source',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.updateIncome.bind(cashflowController)); cashflowController.updateIncome.bind(cashflowController)
);
fastify.delete('/income/:id', { fastify.delete(
schema: { '/income/:id',
description: 'Delete income source', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Delete income source',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.deleteIncome.bind(cashflowController)); cashflowController.deleteIncome.bind(cashflowController)
);
// ===== Expense Routes ===== // ===== Expense Routes =====
fastify.get('/expenses', { fastify.get(
schema: { '/expenses',
description: 'Get all expenses', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get all expenses',
querystring: { tags: ['Cashflow'],
type: 'object', security: [{bearerAuth: []}],
properties: { querystring: {
byCategory: {type: 'string', enum: ['true', 'false']}, type: 'object',
}, properties: {
}, byCategory: {type: 'string', enum: ['true', 'false']}
}
}
}
}, },
}, cashflowController.getAllExpenses.bind(cashflowController)); cashflowController.getAllExpenses.bind(cashflowController)
);
fastify.get('/expenses/total', { fastify.get(
schema: { '/expenses/total',
description: 'Get total monthly expenses', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get total monthly expenses',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.getTotalMonthlyExpenses.bind(cashflowController)); cashflowController.getTotalMonthlyExpenses.bind(cashflowController)
);
fastify.get('/expenses/:id', { fastify.get(
schema: { '/expenses/:id',
description: 'Get expense by ID', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get expense by ID',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.getOneExpense.bind(cashflowController)); cashflowController.getOneExpense.bind(cashflowController)
);
fastify.post('/expenses', { fastify.post(
schema: { '/expenses',
description: 'Create expense', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Create expense',
body: { tags: ['Cashflow'],
type: 'object', security: [{bearerAuth: []}],
required: ['name', 'amount', 'category', 'frequency'], body: {
properties: { type: 'object',
name: {type: 'string'}, required: ['name', 'amount', 'category', 'frequency'],
amount: {type: 'number'}, properties: {
category: {type: 'string'}, name: {type: 'string'},
frequency: {type: 'string'}, amount: {type: 'number'},
dueDate: {type: 'string', format: 'date-time'}, category: {type: 'string'},
notes: {type: 'string'}, frequency: {type: 'string'},
}, dueDate: {type: 'string', format: 'date-time'},
}, notes: {type: 'string'}
}
}
}
}, },
}, cashflowController.createExpense.bind(cashflowController)); cashflowController.createExpense.bind(cashflowController)
);
fastify.put('/expenses/:id', { fastify.put(
schema: { '/expenses/:id',
description: 'Update expense', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Update expense',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.updateExpense.bind(cashflowController)); cashflowController.updateExpense.bind(cashflowController)
);
fastify.delete('/expenses/:id', { fastify.delete(
schema: { '/expenses/:id',
description: 'Delete expense', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Delete expense',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.deleteExpense.bind(cashflowController)); cashflowController.deleteExpense.bind(cashflowController)
);
// ===== Transaction Routes ===== // ===== Transaction Routes =====
fastify.get('/transactions', { fastify.get(
schema: { '/transactions',
description: 'Get all transactions', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get all transactions',
querystring: { tags: ['Cashflow'],
type: 'object', security: [{bearerAuth: []}],
properties: { querystring: {
type: {type: 'string'}, type: 'object',
startDate: {type: 'string', format: 'date-time'}, properties: {
endDate: {type: 'string', format: 'date-time'}, type: {type: 'string'},
}, startDate: {type: 'string', format: 'date-time'},
}, endDate: {type: 'string', format: 'date-time'}
}
}
}
}, },
}, cashflowController.getAllTransactions.bind(cashflowController)); cashflowController.getAllTransactions.bind(cashflowController)
);
fastify.get('/transactions/summary', { fastify.get(
schema: { '/transactions/summary',
description: 'Get cashflow summary for date range', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get cashflow summary for date range',
querystring: { tags: ['Cashflow'],
type: 'object', security: [{bearerAuth: []}],
required: ['startDate', 'endDate'], querystring: {
properties: { type: 'object',
startDate: {type: 'string', format: 'date-time'}, required: ['startDate', 'endDate'],
endDate: {type: 'string', format: 'date-time'}, properties: {
}, startDate: {type: 'string', format: 'date-time'},
}, endDate: {type: 'string', format: 'date-time'}
}
}
}
}, },
}, cashflowController.getCashflowSummary.bind(cashflowController)); cashflowController.getCashflowSummary.bind(cashflowController)
);
fastify.get('/transactions/:id', { fastify.get(
schema: { '/transactions/:id',
description: 'Get transaction by ID', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Get transaction by ID',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.getOneTransaction.bind(cashflowController)); cashflowController.getOneTransaction.bind(cashflowController)
);
fastify.post('/transactions', { fastify.post(
schema: { '/transactions',
description: 'Create transaction', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Create transaction',
body: { tags: ['Cashflow'],
type: 'object', security: [{bearerAuth: []}],
required: ['type', 'category', 'amount', 'date'], body: {
properties: { type: 'object',
type: {type: 'string'}, required: ['type', 'category', 'amount', 'date'],
category: {type: 'string'}, properties: {
amount: {type: 'number'}, type: {type: 'string'},
date: {type: 'string', format: 'date-time'}, category: {type: 'string'},
description: {type: 'string'}, amount: {type: 'number'},
notes: {type: 'string'}, date: {type: 'string', format: 'date-time'},
}, description: {type: 'string'},
}, notes: {type: 'string'}
}
}
}
}, },
}, cashflowController.createTransaction.bind(cashflowController)); cashflowController.createTransaction.bind(cashflowController)
);
fastify.delete('/transactions/:id', { fastify.delete(
schema: { '/transactions/:id',
description: 'Delete transaction', {
tags: ['Cashflow'], schema: {
security: [{bearerAuth: []}], description: 'Delete transaction',
tags: ['Cashflow'],
security: [{bearerAuth: []}]
}
}, },
}, cashflowController.deleteTransaction.bind(cashflowController)); cashflowController.deleteTransaction.bind(cashflowController)
);
} }

View File

@@ -28,9 +28,9 @@ export async function clientRoutes(fastify: FastifyInstance) {
withStats: { withStats: {
type: 'string', type: 'string',
enum: ['true', 'false'], enum: ['true', 'false'],
description: 'Include invoice statistics for each client', description: 'Include invoice statistics for each client'
}, }
}, }
}, },
response: { response: {
200: { 200: {
@@ -49,14 +49,14 @@ export async function clientRoutes(fastify: FastifyInstance) {
address: {type: 'string', nullable: true}, address: {type: 'string', nullable: true},
notes: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, }
}, },
clientController.getAll.bind(clientController) clientController.getAll.bind(clientController)
); );
@@ -76,11 +76,11 @@ export async function clientRoutes(fastify: FastifyInstance) {
description: 'Total revenue', description: 'Total revenue',
type: 'object', type: 'object',
properties: { properties: {
totalRevenue: {type: 'number'}, totalRevenue: {type: 'number'}
}, }
}, }
}, }
}, }
}, },
clientController.getTotalRevenue.bind(clientController) clientController.getTotalRevenue.bind(clientController)
); );
@@ -98,8 +98,8 @@ export async function clientRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
@@ -116,13 +116,13 @@ export async function clientRoutes(fastify: FastifyInstance) {
address: {type: 'string', nullable: true}, address: {type: 'string', nullable: true},
notes: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, },
clientController.getOne.bind(clientController) clientController.getOne.bind(clientController)
); );
@@ -145,19 +145,19 @@ export async function clientRoutes(fastify: FastifyInstance) {
email: {type: 'string', format: 'email'}, email: {type: 'string', format: 'email'},
phone: {type: 'string', maxLength: 50}, phone: {type: 'string', maxLength: 50},
address: {type: 'string'}, address: {type: 'string'},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Client created successfully', description: 'Client created successfully',
type: 'object', type: 'object',
properties: { properties: {
client: {type: 'object'}, client: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
clientController.create.bind(clientController) clientController.create.bind(clientController)
); );
@@ -175,8 +175,8 @@ export async function clientRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
body: { body: {
type: 'object', type: 'object',
@@ -185,19 +185,19 @@ export async function clientRoutes(fastify: FastifyInstance) {
email: {type: 'string', format: 'email'}, email: {type: 'string', format: 'email'},
phone: {type: 'string', maxLength: 50}, phone: {type: 'string', maxLength: 50},
address: {type: 'string'}, address: {type: 'string'},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Client updated successfully', description: 'Client updated successfully',
type: 'object', type: 'object',
properties: { properties: {
client: {type: 'object'}, client: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
clientController.update.bind(clientController) clientController.update.bind(clientController)
); );
@@ -215,16 +215,16 @@ export async function clientRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
204: { 204: {
description: 'Client deleted successfully', description: 'Client deleted successfully',
type: 'null', type: 'null'
}, }
}, }
}, }
}, },
clientController.delete.bind(clientController) clientController.delete.bind(clientController)
); );

View File

@@ -5,11 +5,7 @@ import {AssetRepository} from '../repositories/AssetRepository';
import {LiabilityRepository} from '../repositories/LiabilityRepository'; import {LiabilityRepository} from '../repositories/LiabilityRepository';
import {InvoiceRepository} from '../repositories/InvoiceRepository'; import {InvoiceRepository} from '../repositories/InvoiceRepository';
import {DebtAccountRepository} from '../repositories/DebtAccountRepository'; import {DebtAccountRepository} from '../repositories/DebtAccountRepository';
import { import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository';
IncomeSourceRepository,
ExpenseRepository,
TransactionRepository,
} from '../repositories/CashflowRepository';
import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository'; import {NetWorthSnapshotRepository} from '../repositories/NetWorthSnapshotRepository';
import {authenticate} from '../middleware/auth'; import {authenticate} from '../middleware/auth';
@@ -60,8 +56,8 @@ export async function dashboardRoutes(fastify: FastifyInstance) {
assets: {type: 'number'}, assets: {type: 'number'},
liabilities: {type: 'number'}, liabilities: {type: 'number'},
change: {type: 'number'}, change: {type: 'number'},
lastUpdated: {type: 'string'}, lastUpdated: {type: 'string'}
}, }
}, },
invoices: { invoices: {
type: 'object', type: 'object',
@@ -69,15 +65,15 @@ export async function dashboardRoutes(fastify: FastifyInstance) {
total: {type: 'number'}, total: {type: 'number'},
paid: {type: 'number'}, paid: {type: 'number'},
outstanding: {type: 'number'}, outstanding: {type: 'number'},
overdue: {type: 'number'}, overdue: {type: 'number'}
}, }
}, },
debts: { debts: {
type: 'object', type: 'object',
properties: { properties: {
total: {type: 'number'}, total: {type: 'number'},
accounts: {type: 'number'}, accounts: {type: 'number'}
}, }
}, },
cashflow: { cashflow: {
type: 'object', type: 'object',
@@ -85,21 +81,21 @@ export async function dashboardRoutes(fastify: FastifyInstance) {
monthlyIncome: {type: 'number'}, monthlyIncome: {type: 'number'},
monthlyExpenses: {type: 'number'}, monthlyExpenses: {type: 'number'},
monthlyNet: {type: 'number'}, monthlyNet: {type: 'number'},
last30Days: {type: 'object'}, last30Days: {type: 'object'}
}, }
}, },
assets: { assets: {
type: 'object', type: 'object',
properties: { properties: {
total: {type: 'number'}, total: {type: 'number'},
count: {type: 'number'}, count: {type: 'number'},
allocation: {type: 'array'}, allocation: {type: 'array'}
}, }
}, }
}, }
}, }
}, }
}, }
}, },
dashboardController.getSummary.bind(dashboardController) dashboardController.getSummary.bind(dashboardController)
); );

View File

@@ -42,9 +42,9 @@ export async function debtRoutes(fastify: FastifyInstance) {
withStats: { withStats: {
type: 'string', type: 'string',
enum: ['true', 'false'], enum: ['true', 'false'],
description: 'Include statistics for each category', description: 'Include statistics for each category'
}, }
}, }
}, },
response: { response: {
200: { 200: {
@@ -61,14 +61,14 @@ export async function debtRoutes(fastify: FastifyInstance) {
description: {type: 'string', nullable: true}, description: {type: 'string', nullable: true},
color: {type: 'string', nullable: true}, color: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, }
}, },
categoryController.getAll.bind(categoryController) categoryController.getAll.bind(categoryController)
); );
@@ -86,8 +86,8 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
@@ -102,13 +102,13 @@ export async function debtRoutes(fastify: FastifyInstance) {
description: {type: 'string', nullable: true}, description: {type: 'string', nullable: true},
color: {type: 'string', nullable: true}, color: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, },
categoryController.getOne.bind(categoryController) categoryController.getOne.bind(categoryController)
); );
@@ -129,19 +129,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
properties: { properties: {
name: {type: 'string', minLength: 1, maxLength: 255}, name: {type: 'string', minLength: 1, maxLength: 255},
description: {type: 'string'}, description: {type: 'string'},
color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}, color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Debt category created successfully', description: 'Debt category created successfully',
type: 'object', type: 'object',
properties: { properties: {
category: {type: 'object'}, category: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
categoryController.create.bind(categoryController) categoryController.create.bind(categoryController)
); );
@@ -159,27 +159,27 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
body: { body: {
type: 'object', type: 'object',
properties: { properties: {
name: {type: 'string', minLength: 1, maxLength: 255}, name: {type: 'string', minLength: 1, maxLength: 255},
description: {type: 'string'}, description: {type: 'string'},
color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}, color: {type: 'string', pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Debt category updated successfully', description: 'Debt category updated successfully',
type: 'object', type: 'object',
properties: { properties: {
category: {type: 'object'}, category: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
categoryController.update.bind(categoryController) categoryController.update.bind(categoryController)
); );
@@ -197,16 +197,16 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
204: { 204: {
description: 'Debt category deleted successfully', description: 'Debt category deleted successfully',
type: 'null', type: 'null'
}, }
}, }
}, }
}, },
categoryController.delete.bind(categoryController) categoryController.delete.bind(categoryController)
); );
@@ -227,19 +227,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
type: 'object', type: 'object',
properties: { properties: {
withStats: {type: 'string', enum: ['true', 'false']}, withStats: {type: 'string', enum: ['true', 'false']},
categoryId: {type: 'string', description: 'Filter by category ID'}, categoryId: {type: 'string', description: 'Filter by category ID'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'List of debt accounts', description: 'List of debt accounts',
type: 'object', type: 'object',
properties: { properties: {
accounts: {type: 'array', items: {type: 'object'}}, accounts: {type: 'array', items: {type: 'object'}}
}, }
}, }
}, }
}, }
}, },
accountController.getAll.bind(accountController) accountController.getAll.bind(accountController)
); );
@@ -259,11 +259,11 @@ export async function debtRoutes(fastify: FastifyInstance) {
description: 'Total debt', description: 'Total debt',
type: 'object', type: 'object',
properties: { properties: {
totalDebt: {type: 'number'}, totalDebt: {type: 'number'}
}, }
}, }
}, }
}, }
}, },
accountController.getTotalDebt.bind(accountController) accountController.getTotalDebt.bind(accountController)
); );
@@ -281,19 +281,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Debt account details', description: 'Debt account details',
type: 'object', type: 'object',
properties: { properties: {
account: {type: 'object'}, account: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
accountController.getOne.bind(accountController) accountController.getOne.bind(accountController)
); );
@@ -321,19 +321,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
interestRate: {type: 'number', minimum: 0, maximum: 100}, interestRate: {type: 'number', minimum: 0, maximum: 100},
minimumPayment: {type: 'number', minimum: 0}, minimumPayment: {type: 'number', minimum: 0},
dueDate: {type: 'string', format: 'date-time'}, dueDate: {type: 'string', format: 'date-time'},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Debt account created successfully', description: 'Debt account created successfully',
type: 'object', type: 'object',
properties: { properties: {
account: {type: 'object'}, account: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
accountController.create.bind(accountController) accountController.create.bind(accountController)
); );
@@ -351,8 +351,8 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
body: { body: {
type: 'object', type: 'object',
@@ -364,19 +364,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
interestRate: {type: 'number', minimum: 0, maximum: 100}, interestRate: {type: 'number', minimum: 0, maximum: 100},
minimumPayment: {type: 'number', minimum: 0}, minimumPayment: {type: 'number', minimum: 0},
dueDate: {type: 'string', format: 'date-time'}, dueDate: {type: 'string', format: 'date-time'},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Debt account updated successfully', description: 'Debt account updated successfully',
type: 'object', type: 'object',
properties: { properties: {
account: {type: 'object'}, account: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
accountController.update.bind(accountController) accountController.update.bind(accountController)
); );
@@ -394,16 +394,16 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
204: { 204: {
description: 'Debt account deleted successfully', description: 'Debt account deleted successfully',
type: 'null', type: 'null'
}, }
}, }
}, }
}, },
accountController.delete.bind(accountController) accountController.delete.bind(accountController)
); );
@@ -425,19 +425,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
properties: { properties: {
accountId: {type: 'string', description: 'Filter by account ID'}, accountId: {type: 'string', description: 'Filter by account ID'},
startDate: {type: 'string', format: 'date-time'}, startDate: {type: 'string', format: 'date-time'},
endDate: {type: 'string', format: 'date-time'}, endDate: {type: 'string', format: 'date-time'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'List of debt payments', description: 'List of debt payments',
type: 'object', type: 'object',
properties: { properties: {
payments: {type: 'array', items: {type: 'object'}}, payments: {type: 'array', items: {type: 'object'}}
}, }
}, }
}, }
}, }
}, },
paymentController.getAll.bind(paymentController) paymentController.getAll.bind(paymentController)
); );
@@ -457,11 +457,11 @@ export async function debtRoutes(fastify: FastifyInstance) {
description: 'Total payments', description: 'Total payments',
type: 'object', type: 'object',
properties: { properties: {
totalPayments: {type: 'number'}, totalPayments: {type: 'number'}
}, }
}, }
}, }
}, }
}, },
paymentController.getTotalPayments.bind(paymentController) paymentController.getTotalPayments.bind(paymentController)
); );
@@ -479,19 +479,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Debt payment details', description: 'Debt payment details',
type: 'object', type: 'object',
properties: { properties: {
payment: {type: 'object'}, payment: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
paymentController.getOne.bind(paymentController) paymentController.getOne.bind(paymentController)
); );
@@ -513,19 +513,19 @@ export async function debtRoutes(fastify: FastifyInstance) {
accountId: {type: 'string', format: 'uuid'}, accountId: {type: 'string', format: 'uuid'},
amount: {type: 'number', minimum: 0.01}, amount: {type: 'number', minimum: 0.01},
paymentDate: {type: 'string', format: 'date-time'}, paymentDate: {type: 'string', format: 'date-time'},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Debt payment created successfully', description: 'Debt payment created successfully',
type: 'object', type: 'object',
properties: { properties: {
payment: {type: 'object'}, payment: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
paymentController.create.bind(paymentController) paymentController.create.bind(paymentController)
); );
@@ -543,16 +543,16 @@ export async function debtRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
204: { 204: {
description: 'Debt payment deleted successfully', description: 'Debt payment deleted successfully',
type: 'null', type: 'null'
}, }
}, }
}, }
}, },
paymentController.delete.bind(paymentController) paymentController.delete.bind(paymentController)
); );

View File

@@ -28,8 +28,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
type: 'object', type: 'object',
properties: { properties: {
clientId: {type: 'string', description: 'Filter by client ID'}, clientId: {type: 'string', description: 'Filter by client ID'},
status: {type: 'string', description: 'Filter by status'}, status: {type: 'string', description: 'Filter by status'}
}, }
}, },
response: { response: {
200: { 200: {
@@ -52,14 +52,14 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
notes: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true},
terms: {type: 'string', nullable: true}, terms: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, }
}, },
invoiceController.getAll.bind(invoiceController) invoiceController.getAll.bind(invoiceController)
); );
@@ -85,13 +85,13 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
total: {type: 'number'}, total: {type: 'number'},
paid: {type: 'number'}, paid: {type: 'number'},
outstanding: {type: 'number'}, outstanding: {type: 'number'},
overdue: {type: 'number'}, overdue: {type: 'number'}
}, }
}, }
}, }
}, }
}, }
}, }
}, },
invoiceController.getStats.bind(invoiceController) invoiceController.getStats.bind(invoiceController)
); );
@@ -111,11 +111,11 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
description: 'List of overdue invoices', description: 'List of overdue invoices',
type: 'object', type: 'object',
properties: { properties: {
invoices: {type: 'array', items: {type: 'object'}}, invoices: {type: 'array', items: {type: 'object'}}
}, }
}, }
}, }
}, }
}, },
invoiceController.getOverdue.bind(invoiceController) invoiceController.getOverdue.bind(invoiceController)
); );
@@ -133,8 +133,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
@@ -155,13 +155,13 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
notes: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true},
terms: {type: 'string', nullable: true}, terms: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, },
invoiceController.getOne.bind(invoiceController) invoiceController.getOne.bind(invoiceController)
); );
@@ -193,24 +193,24 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
description: {type: 'string', minLength: 1}, description: {type: 'string', minLength: 1},
quantity: {type: 'number', minimum: 1}, quantity: {type: 'number', minimum: 1},
unitPrice: {type: 'number', minimum: 0}, unitPrice: {type: 'number', minimum: 0},
amount: {type: 'number', minimum: 0}, amount: {type: 'number', minimum: 0}
}, }
}, }
}, },
notes: {type: 'string'}, notes: {type: 'string'},
terms: {type: 'string'}, terms: {type: 'string'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Invoice created successfully', description: 'Invoice created successfully',
type: 'object', type: 'object',
properties: { properties: {
invoice: {type: 'object'}, invoice: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
invoiceController.create.bind(invoiceController) invoiceController.create.bind(invoiceController)
); );
@@ -228,8 +228,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
body: { body: {
type: 'object', type: 'object',
@@ -246,24 +246,24 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
description: {type: 'string', minLength: 1}, description: {type: 'string', minLength: 1},
quantity: {type: 'number', minimum: 1}, quantity: {type: 'number', minimum: 1},
unitPrice: {type: 'number', minimum: 0}, unitPrice: {type: 'number', minimum: 0},
amount: {type: 'number', minimum: 0}, amount: {type: 'number', minimum: 0}
}, }
}, }
}, },
notes: {type: 'string'}, notes: {type: 'string'},
terms: {type: 'string'}, terms: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Invoice updated successfully', description: 'Invoice updated successfully',
type: 'object', type: 'object',
properties: { properties: {
invoice: {type: 'object'}, invoice: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
invoiceController.update.bind(invoiceController) invoiceController.update.bind(invoiceController)
); );
@@ -281,8 +281,8 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
body: { body: {
type: 'object', type: 'object',
@@ -290,20 +290,20 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
properties: { properties: {
status: { status: {
type: 'string', type: 'string',
enum: ['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED'], enum: ['DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED']
}, }
}, }
}, },
response: { response: {
200: { 200: {
description: 'Invoice status updated successfully', description: 'Invoice status updated successfully',
type: 'object', type: 'object',
properties: { properties: {
invoice: {type: 'object'}, invoice: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
invoiceController.updateStatus.bind(invoiceController) invoiceController.updateStatus.bind(invoiceController)
); );
@@ -321,16 +321,16 @@ export async function invoiceRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
204: { 204: {
description: 'Invoice deleted successfully', description: 'Invoice deleted successfully',
type: 'null', type: 'null'
}, }
}, }
}, }
}, },
invoiceController.delete.bind(invoiceController) invoiceController.delete.bind(invoiceController)
); );

View File

@@ -42,14 +42,14 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
creditor: {type: 'string', nullable: true}, creditor: {type: 'string', nullable: true},
notes: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, }
}, },
liabilityController.getAll.bind(liabilityController) liabilityController.getAll.bind(liabilityController)
); );
@@ -69,11 +69,11 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
description: 'Total liability value', description: 'Total liability value',
type: 'object', type: 'object',
properties: { properties: {
totalValue: {type: 'number'}, totalValue: {type: 'number'}
}, }
}, }
}, }
}, }
}, },
liabilityController.getTotalValue.bind(liabilityController) liabilityController.getTotalValue.bind(liabilityController)
); );
@@ -97,13 +97,13 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
type: 'object', type: 'object',
additionalProperties: { additionalProperties: {
type: 'array', type: 'array',
items: {type: 'object'}, items: {type: 'object'}
}, }
}, }
}, }
}, }
}, }
}, }
}, },
liabilityController.getByType.bind(liabilityController) liabilityController.getByType.bind(liabilityController)
); );
@@ -121,8 +121,8 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
@@ -142,13 +142,13 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
creditor: {type: 'string', nullable: true}, creditor: {type: 'string', nullable: true},
notes: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'},
updatedAt: {type: 'string'}, updatedAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, },
liabilityController.getOne.bind(liabilityController) liabilityController.getOne.bind(liabilityController)
); );
@@ -174,19 +174,19 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
minimumPayment: {type: 'number', minimum: 0}, minimumPayment: {type: 'number', minimum: 0},
dueDate: {type: 'string', format: 'date-time'}, dueDate: {type: 'string', format: 'date-time'},
creditor: {type: 'string', maxLength: 255}, creditor: {type: 'string', maxLength: 255},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Liability created successfully', description: 'Liability created successfully',
type: 'object', type: 'object',
properties: { properties: {
liability: {type: 'object'}, liability: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
liabilityController.create.bind(liabilityController) liabilityController.create.bind(liabilityController)
); );
@@ -204,8 +204,8 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
body: { body: {
type: 'object', type: 'object',
@@ -217,19 +217,19 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
minimumPayment: {type: 'number', minimum: 0}, minimumPayment: {type: 'number', minimum: 0},
dueDate: {type: 'string', format: 'date-time'}, dueDate: {type: 'string', format: 'date-time'},
creditor: {type: 'string', maxLength: 255}, creditor: {type: 'string', maxLength: 255},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Liability updated successfully', description: 'Liability updated successfully',
type: 'object', type: 'object',
properties: { properties: {
liability: {type: 'object'}, liability: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
liabilityController.update.bind(liabilityController) liabilityController.update.bind(liabilityController)
); );
@@ -247,16 +247,16 @@ export async function liabilityRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
204: { 204: {
description: 'Liability deleted successfully', description: 'Liability deleted successfully',
type: 'null', type: 'null'
}, }
}, }
}, }
}, },
liabilityController.delete.bind(liabilityController) liabilityController.delete.bind(liabilityController)
); );

View File

@@ -35,11 +35,11 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
totalLiabilities: {type: 'number'}, totalLiabilities: {type: 'number'},
netWorth: {type: 'number'}, netWorth: {type: 'number'},
asOf: {type: 'string'}, asOf: {type: 'string'},
isCalculated: {type: 'boolean'}, isCalculated: {type: 'boolean'}
}, }
}, }
}, }
}, }
}, },
netWorthController.getCurrent.bind(netWorthController) netWorthController.getCurrent.bind(netWorthController)
); );
@@ -70,14 +70,14 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
totalLiabilities: {type: 'number'}, totalLiabilities: {type: 'number'},
netWorth: {type: 'number'}, netWorth: {type: 'number'},
notes: {type: 'string', nullable: true}, notes: {type: 'string', nullable: true},
createdAt: {type: 'string'}, createdAt: {type: 'string'}
}, }
}, }
}, }
}, }
}, }
}, }
}, }
}, },
netWorthController.getAllSnapshots.bind(netWorthController) netWorthController.getAllSnapshots.bind(netWorthController)
); );
@@ -97,19 +97,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
required: ['startDate', 'endDate'], required: ['startDate', 'endDate'],
properties: { properties: {
startDate: {type: 'string', format: 'date-time'}, startDate: {type: 'string', format: 'date-time'},
endDate: {type: 'string', format: 'date-time'}, endDate: {type: 'string', format: 'date-time'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Snapshots in date range', description: 'Snapshots in date range',
type: 'object', type: 'object',
properties: { properties: {
snapshots: {type: 'array', items: {type: 'object'}}, snapshots: {type: 'array', items: {type: 'object'}}
}, }
}, }
}, }
}, }
}, },
netWorthController.getByDateRange.bind(netWorthController) netWorthController.getByDateRange.bind(netWorthController)
); );
@@ -127,8 +127,8 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
querystring: { querystring: {
type: 'object', type: 'object',
properties: { properties: {
limit: {type: 'string', description: 'Number of periods to include (default: 12)'}, limit: {type: 'string', description: 'Number of periods to include (default: 12)'}
}, }
}, },
response: { response: {
200: { 200: {
@@ -143,14 +143,14 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
date: {type: 'string'}, date: {type: 'string'},
netWorth: {type: 'number'}, netWorth: {type: 'number'},
growthAmount: {type: 'number'}, growthAmount: {type: 'number'},
growthPercent: {type: 'number'}, growthPercent: {type: 'number'}
}, }
}, }
}, }
}, }
}, }
}, }
}, }
}, },
netWorthController.getGrowthStats.bind(netWorthController) netWorthController.getGrowthStats.bind(netWorthController)
); );
@@ -168,19 +168,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
200: { 200: {
description: 'Snapshot details', description: 'Snapshot details',
type: 'object', type: 'object',
properties: { properties: {
snapshot: {type: 'object'}, snapshot: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
netWorthController.getOne.bind(netWorthController) netWorthController.getOne.bind(netWorthController)
); );
@@ -203,19 +203,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
totalAssets: {type: 'number', minimum: 0}, totalAssets: {type: 'number', minimum: 0},
totalLiabilities: {type: 'number', minimum: 0}, totalLiabilities: {type: 'number', minimum: 0},
netWorth: {type: 'number'}, netWorth: {type: 'number'},
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Snapshot created successfully', description: 'Snapshot created successfully',
type: 'object', type: 'object',
properties: { properties: {
snapshot: {type: 'object'}, snapshot: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
netWorthController.createSnapshot.bind(netWorthController) netWorthController.createSnapshot.bind(netWorthController)
); );
@@ -233,19 +233,19 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
body: { body: {
type: 'object', type: 'object',
properties: { properties: {
notes: {type: 'string'}, notes: {type: 'string'}
}, }
}, },
response: { response: {
201: { 201: {
description: 'Snapshot created successfully', description: 'Snapshot created successfully',
type: 'object', type: 'object',
properties: { properties: {
snapshot: {type: 'object'}, snapshot: {type: 'object'}
}, }
}, }
}, }
}, }
}, },
netWorthController.createFromCurrent.bind(netWorthController) netWorthController.createFromCurrent.bind(netWorthController)
); );
@@ -263,16 +263,16 @@ export async function netWorthRoutes(fastify: FastifyInstance) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
id: {type: 'string'}, id: {type: 'string'}
}, }
}, },
response: { response: {
204: { 204: {
description: 'Snapshot deleted successfully', description: 'Snapshot deleted successfully',
type: 'null', type: 'null'
}, }
}, }
}, }
}, },
netWorthController.delete.bind(netWorthController) netWorthController.delete.bind(netWorthController)
); );

View File

@@ -20,24 +20,28 @@ import {dashboardRoutes} from './routes/dashboard.routes';
* Implements Single Responsibility: Server configuration * Implements Single Responsibility: Server configuration
*/ */
export async function buildServer() { export async function buildServer() {
if (env.NODE_ENV !== 'production') {
console.log('Development mode enabled. Environment variables [%o]', env);
}
const fastify = Fastify({ const fastify = Fastify({
logger: { logger: {
level: env.NODE_ENV === 'development' ? 'info' : 'error', level: env.NODE_ENV === 'development' ? 'info' : 'error',
transport: env.NODE_ENV === 'development' ? {target: 'pino-pretty'} : undefined, transport: env.NODE_ENV === 'development' ? {target: 'pino-pretty'} : undefined
}, }
}); });
// Register plugins // Register plugins
await fastify.register(cors, { await fastify.register(cors, {
origin: env.CORS_ORIGIN, origin: env.CORS_ORIGIN,
credentials: true, credentials: true
}); });
await fastify.register(jwt, { await fastify.register(jwt, {
secret: env.JWT_SECRET, secret: env.JWT_SECRET,
sign: { sign: {
expiresIn: env.JWT_EXPIRES_IN, expiresIn: env.JWT_EXPIRES_IN
}, }
}); });
// Register Swagger for API documentation // Register Swagger for API documentation
@@ -46,32 +50,32 @@ export async function buildServer() {
info: { info: {
title: 'Personal Finances API', title: 'Personal Finances API',
description: 'API for managing personal finances including assets, liabilities, invoices, and more', description: 'API for managing personal finances including assets, liabilities, invoices, and more',
version: '1.0.0', version: '1.0.0'
}, },
servers: [ servers: [
{ {
url: `http://localhost:${env.PORT}`, url: `http://localhost:${env.PORT}`,
description: 'Development server', description: 'Development server'
}, }
], ],
components: { components: {
securitySchemes: { securitySchemes: {
bearerAuth: { bearerAuth: {
type: 'http', type: 'http',
scheme: 'bearer', scheme: 'bearer',
bearerFormat: 'JWT', bearerFormat: 'JWT'
}, }
}, }
}, }
}, }
}); });
await fastify.register(swaggerUi, { await fastify.register(swaggerUi, {
routePrefix: '/docs', routePrefix: '/docs',
uiConfig: { uiConfig: {
docExpansion: 'list', docExpansion: 'list',
deepLinking: false, deepLinking: false
}, }
}); });
// Register error handler // Register error handler
@@ -80,7 +84,7 @@ export async function buildServer() {
// Health check // Health check
fastify.get('/health', async () => ({ fastify.get('/health', async () => ({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
})); }));
// Register routes // Register routes

View File

@@ -44,7 +44,7 @@ export class AssetService {
name: data.name, name: data.name,
type: data.type, type: data.type,
value: data.value, value: data.value,
user: {connect: {id: userId}}, user: {connect: {id: userId}}
}); });
} }
@@ -58,7 +58,7 @@ export class AssetService {
this.validateAssetData({ this.validateAssetData({
name: data.name || asset.name, name: data.name || asset.name,
type: (data.type || asset.type) as AssetType, type: (data.type || asset.type) as AssetType,
value: data.value !== undefined ? data.value : asset.value, value: data.value !== undefined ? data.value : asset.value
}); });
} }
@@ -80,14 +80,17 @@ export class AssetService {
async getByType(userId: string): Promise<Record<string, Asset[]>> { async getByType(userId: string): Promise<Record<string, Asset[]>> {
const assets = await this.assetRepository.findAllByUser(userId); const assets = await this.assetRepository.findAllByUser(userId);
return assets.reduce((acc, asset) => { return assets.reduce(
const type = asset.type; (acc, asset) => {
if (!acc[type]) { const type = asset.type;
acc[type] = []; if (!acc[type]) {
} acc[type] = [];
acc[type].push(asset); }
return acc; acc[type].push(asset);
}, {} as Record<string, Asset[]>); return acc;
},
{} as Record<string, Asset[]>
);
} }
private validateAssetData(data: CreateAssetDTO): void { private validateAssetData(data: CreateAssetDTO): void {

View File

@@ -33,7 +33,7 @@ export class AuthService {
const user = await this.userRepository.create({ const user = await this.userRepository.create({
email, email,
password: hashedPassword, password: hashedPassword,
name, name
}); });
// Create default debt categories for new user // Create default debt categories for new user

View File

@@ -1,9 +1,5 @@
import {IncomeSource, Expense, Transaction} from '@prisma/client'; import {IncomeSource, Expense, Transaction} from '@prisma/client';
import { import {IncomeSourceRepository, ExpenseRepository, TransactionRepository} from '../repositories/CashflowRepository';
IncomeSourceRepository,
ExpenseRepository,
TransactionRepository,
} from '../repositories/CashflowRepository';
import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors'; import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors';
export interface CreateIncomeSourceDTO { export interface CreateIncomeSourceDTO {
@@ -47,7 +43,7 @@ export class CashflowService {
return this.incomeRepository.create({ return this.incomeRepository.create({
...data, ...data,
user: {connect: {id: userId}}, user: {connect: {id: userId}}
}); });
} }
@@ -85,7 +81,7 @@ export class CashflowService {
return this.expenseRepository.create({ return this.expenseRepository.create({
...data, ...data,
user: {connect: {id: userId}}, user: {connect: {id: userId}}
}); });
} }
@@ -128,7 +124,7 @@ export class CashflowService {
return this.transactionRepository.create({ return this.transactionRepository.create({
...data, ...data,
user: {connect: {id: userId}}, user: {connect: {id: userId}}
}); });
} }

View File

@@ -45,8 +45,8 @@ export class ClientService {
address: data.address, address: data.address,
notes: data.notes, notes: data.notes,
user: { user: {
connect: {id: userId}, connect: {id: userId}
}, }
}); });
} }

View File

@@ -48,18 +48,14 @@ export class DashboardService {
// Get recent transactions (last 30 days) // Get recent transactions (last 30 days)
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentCashflow = await this.transactionRepository.getCashflowSummary( const recentCashflow = await this.transactionRepository.getCashflowSummary(userId, thirtyDaysAgo, new Date());
userId,
thirtyDaysAgo,
new Date()
);
// Get assets by type // Get assets by type
const assetsByType = await this.assetRepository.getByType(userId); const assetsByType = await this.assetRepository.getByType(userId);
const assetAllocation = Object.entries(assetsByType).map(([type, assets]) => ({ const assetAllocation = Object.entries(assetsByType).map(([type, assets]) => ({
type, type,
count: assets.length, count: assets.length,
totalValue: assets.reduce((sum, asset) => sum + asset.currentValue, 0), totalValue: assets.reduce((sum, asset) => sum + asset.currentValue, 0)
})); }));
return { return {
@@ -68,29 +64,29 @@ export class DashboardService {
assets: totalAssets, assets: totalAssets,
liabilities: totalLiabilities, liabilities: totalLiabilities,
change: netWorthChange, change: netWorthChange,
lastUpdated: new Date(), lastUpdated: new Date()
}, },
invoices: { invoices: {
total: invoiceStats.totalInvoices, total: invoiceStats.totalInvoices,
paid: invoiceStats.paidInvoices, paid: invoiceStats.paidInvoices,
outstanding: invoiceStats.outstandingAmount, outstanding: invoiceStats.outstandingAmount,
overdue: invoiceStats.overdueInvoices, overdue: invoiceStats.overdueInvoices
}, },
debts: { debts: {
total: totalDebt, total: totalDebt,
accounts: (await this.debtAccountRepository.findAllByUser(userId)).length, accounts: (await this.debtAccountRepository.findAllByUser(userId)).length
}, },
cashflow: { cashflow: {
monthlyIncome: totalMonthlyIncome, monthlyIncome: totalMonthlyIncome,
monthlyExpenses: totalMonthlyExpenses, monthlyExpenses: totalMonthlyExpenses,
monthlyNet: monthlyCashflow, monthlyNet: monthlyCashflow,
last30Days: recentCashflow, last30Days: recentCashflow
}, },
assets: { assets: {
total: totalAssets, total: totalAssets,
count: (await this.assetRepository.findAllByUser(userId)).length, count: (await this.assetRepository.findAllByUser(userId)).length,
allocation: assetAllocation, allocation: assetAllocation
}, }
}; };
} }
} }

View File

@@ -64,8 +64,8 @@ export class DebtAccountService {
dueDate: data.dueDate, dueDate: data.dueDate,
notes: data.notes, notes: data.notes,
category: { category: {
connect: {id: data.categoryId}, connect: {id: data.categoryId}
}, }
}); });
} }

View File

@@ -32,7 +32,7 @@ export class DebtCategoryService {
{name: 'Auto Loans', description: 'Car and vehicle loans', color: '#10b981'}, {name: 'Auto Loans', description: 'Car and vehicle loans', color: '#10b981'},
{name: 'Mortgages', description: 'Home mortgages', color: '#f59e0b'}, {name: 'Mortgages', description: 'Home mortgages', color: '#f59e0b'},
{name: 'Personal Loans', description: 'Personal loan debts', color: '#8b5cf6'}, {name: 'Personal Loans', description: 'Personal loan debts', color: '#8b5cf6'},
{name: 'Other', description: 'Other debt types', color: '#6b7280'}, {name: 'Other', description: 'Other debt types', color: '#6b7280'}
]; ];
const categories: DebtCategory[] = []; const categories: DebtCategory[] = [];
@@ -43,8 +43,8 @@ export class DebtCategoryService {
description: category.description, description: category.description,
color: category.color, color: category.color,
user: { user: {
connect: {id: userId}, connect: {id: userId}
}, }
}); });
categories.push(created); categories.push(created);
} }
@@ -69,8 +69,8 @@ export class DebtCategoryService {
description: data.description, description: data.description,
color: data.color, color: data.color,
user: { user: {
connect: {id: userId}, connect: {id: userId}
}, }
}); });
} }

View File

@@ -42,14 +42,14 @@ export class DebtPaymentService {
paymentDate: data.paymentDate, paymentDate: data.paymentDate,
notes: data.notes, notes: data.notes,
account: { account: {
connect: {id: data.accountId}, connect: {id: data.accountId}
}, }
}); });
// Update account current balance // Update account current balance
const newBalance = account.currentBalance - data.amount; const newBalance = account.currentBalance - data.amount;
await this.accountRepository.update(data.accountId, { await this.accountRepository.update(data.accountId, {
currentBalance: Math.max(0, newBalance), // Don't allow negative balance currentBalance: Math.max(0, newBalance) // Don't allow negative balance
}); });
return payment; return payment;
@@ -114,7 +114,7 @@ export class DebtPaymentService {
if (account) { if (account) {
const newBalance = account.currentBalance + payment.amount; const newBalance = account.currentBalance + payment.amount;
await this.accountRepository.update(payment.accountId, { await this.accountRepository.update(payment.accountId, {
currentBalance: newBalance, currentBalance: newBalance
}); });
} }

View File

@@ -66,8 +66,7 @@ export class InvoiceService {
this.validateInvoiceData(data); this.validateInvoiceData(data);
// Generate invoice number if not provided // Generate invoice number if not provided
const invoiceNumber = const invoiceNumber = data.invoiceNumber || (await this.invoiceRepository.generateInvoiceNumber(userId));
data.invoiceNumber || (await this.invoiceRepository.generateInvoiceNumber(userId));
// Check if invoice number already exists // Check if invoice number already exists
const exists = await this.invoiceRepository.invoiceNumberExists(userId, invoiceNumber); const exists = await this.invoiceRepository.invoiceNumberExists(userId, invoiceNumber);
@@ -80,7 +79,7 @@ export class InvoiceService {
description: item.description, description: item.description,
quantity: item.quantity, quantity: item.quantity,
unitPrice: item.unitPrice, unitPrice: item.unitPrice,
total: item.quantity * item.unitPrice, total: item.quantity * item.unitPrice
})); }));
const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0); const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0);
@@ -99,8 +98,8 @@ export class InvoiceService {
user: {connect: {id: userId}}, user: {connect: {id: userId}},
client: {connect: {id: data.clientId}}, client: {connect: {id: data.clientId}},
lineItems: { lineItems: {
create: lineItems, create: lineItems
}, }
}); });
} }
@@ -127,7 +126,7 @@ export class InvoiceService {
description: item.description, description: item.description,
quantity: item.quantity, quantity: item.quantity,
unitPrice: item.unitPrice, unitPrice: item.unitPrice,
total: item.quantity * item.unitPrice, total: item.quantity * item.unitPrice
})); }));
const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0); const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0);
@@ -137,7 +136,7 @@ export class InvoiceService {
updateData.total = total; updateData.total = total;
updateData.lineItems = { updateData.lineItems = {
deleteMany: {}, deleteMany: {},
create: lineItems, create: lineItems
}; };
} }
@@ -164,7 +163,7 @@ export class InvoiceService {
overdue: 0, overdue: 0,
totalAmount: 0, totalAmount: 0,
paidAmount: 0, paidAmount: 0,
outstandingAmount: 0, outstandingAmount: 0
}; };
for (const inv of invoices) { for (const inv of invoices) {

View File

@@ -48,8 +48,8 @@ export class LiabilityService {
creditor: data.creditor, creditor: data.creditor,
notes: data.notes, notes: data.notes,
user: { user: {
connect: {id: userId}, connect: {id: userId}
}, }
}); });
} }

View File

@@ -40,9 +40,7 @@ export class NetWorthService {
const calculatedNetWorth = data.totalAssets - data.totalLiabilities; const calculatedNetWorth = data.totalAssets - data.totalLiabilities;
if (Math.abs(calculatedNetWorth - data.netWorth) > 0.01) { if (Math.abs(calculatedNetWorth - data.netWorth) > 0.01) {
// Allow small floating point differences // Allow small floating point differences
throw new ValidationError( throw new ValidationError(`Net worth calculation mismatch. Expected ${calculatedNetWorth}, got ${data.netWorth}`);
`Net worth calculation mismatch. Expected ${calculatedNetWorth}, got ${data.netWorth}`
);
} }
return this.snapshotRepository.create({ return this.snapshotRepository.create({
@@ -52,8 +50,8 @@ export class NetWorthService {
netWorth: data.netWorth, netWorth: data.netWorth,
notes: data.notes, notes: data.notes,
user: { user: {
connect: {id: userId}, connect: {id: userId}
}, }
}); });
} }
@@ -70,7 +68,7 @@ export class NetWorthService {
totalAssets, totalAssets,
totalLiabilities, totalLiabilities,
netWorth, netWorth,
notes, notes
}); });
} }
@@ -84,11 +82,7 @@ export class NetWorthService {
/** /**
* Get snapshots within a date range * Get snapshots within a date range
*/ */
async getSnapshotsByDateRange( async getSnapshotsByDateRange(userId: string, startDate: Date, endDate: Date): Promise<NetWorthSnapshot[]> {
userId: string,
startDate: Date,
endDate: Date
): Promise<NetWorthSnapshot[]> {
return this.snapshotRepository.getByDateRange(userId, startDate, endDate); return this.snapshotRepository.getByDateRange(userId, startDate, endDate);
} }
@@ -106,8 +100,7 @@ export class NetWorthService {
// If we have a recent snapshot (within last 24 hours), use it // If we have a recent snapshot (within last 24 hours), use it
if (latestSnapshot) { if (latestSnapshot) {
const hoursSinceSnapshot = const hoursSinceSnapshot = (Date.now() - latestSnapshot.date.getTime()) / (1000 * 60 * 60);
(Date.now() - latestSnapshot.date.getTime()) / (1000 * 60 * 60);
if (hoursSinceSnapshot < 24) { if (hoursSinceSnapshot < 24) {
return { return {
@@ -115,7 +108,7 @@ export class NetWorthService {
totalLiabilities: latestSnapshot.totalLiabilities, totalLiabilities: latestSnapshot.totalLiabilities,
netWorth: latestSnapshot.netWorth, netWorth: latestSnapshot.netWorth,
asOf: latestSnapshot.date, asOf: latestSnapshot.date,
isCalculated: false, isCalculated: false
}; };
} }
} }
@@ -129,7 +122,7 @@ export class NetWorthService {
totalLiabilities, totalLiabilities,
netWorth: totalAssets - totalLiabilities, netWorth: totalAssets - totalLiabilities,
asOf: new Date(), asOf: new Date(),
isCalculated: true, isCalculated: true
}; };
} }

View File

@@ -12,4 +12,3 @@ declare module '@fastify/jwt' {
}; };
} }
} }

View File

@@ -33,7 +33,7 @@ export class PasswordService {
return { return {
valid: errors.length === 0, valid: errors.length === 0,
errors, errors
}; };
} }
} }

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=="],
}
}

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import {lazy, Suspense} from 'react'; import {lazy, Suspense, useEffect} from 'react';
import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom'; import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom';
import {useAppSelector} from '@/store'; import {useAppSelector, useAppDispatch} from '@/store';
import {loadUserFromStorage} from '@/store/slices/userSlice';
import {fetchAssets, fetchLiabilities, fetchSnapshots} from '@/store/slices/netWorthSlice';
import {fetchIncomeSources, fetchExpenses, fetchTransactions} from '@/store/slices/cashflowSlice';
import Layout from '@/components/Layout'; import Layout from '@/components/Layout';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
@@ -20,7 +23,30 @@ const PageLoader = () => (
); );
function AppRoutes() { function AppRoutes() {
const {isAuthenticated} = useAppSelector(state => state.user); const dispatch = useAppDispatch();
const {isAuthenticated, isLoading} = useAppSelector(state => state.user);
// Load user from storage on app start
useEffect(() => {
dispatch(loadUserFromStorage());
}, [dispatch]);
// Fetch all data when user is authenticated
useEffect(() => {
if (isAuthenticated) {
dispatch(fetchAssets());
dispatch(fetchLiabilities());
dispatch(fetchSnapshots());
dispatch(fetchIncomeSources());
dispatch(fetchExpenses());
dispatch(fetchTransactions());
}
}, [isAuthenticated, dispatch]);
// Show loading while checking authentication
if (isLoading) {
return <PageLoader />;
}
return ( return (
<Routes> <Routes>

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; 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'; import {validatePositiveNumber, validateRequired} from '@/lib/validation';
interface Props { interface Props {
@@ -60,10 +60,11 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
dispatch( dispatch(
updateAsset({ updateAsset({
id: asset.id, id: asset.id,
name: form.name.trim(), data: {
type: form.type as (typeof assetTypes)[number], name: form.name.trim(),
value: valueNum, type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER',
updatedAt: new Date().toISOString() value: valueNum,
}
}) })
); );
onOpenChange(false); onOpenChange(false);
@@ -72,7 +73,7 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
const handleDelete = () => { const handleDelete = () => {
if (!asset) return; if (!asset) return;
if (confirm(`Are you sure you want to delete "${asset.name}"?`)) { if (confirm(`Are you sure you want to delete "${asset.name}"?`)) {
dispatch(removeAsset(asset.id)); dispatch(deleteAsset(asset.id));
onOpenChange(false); onOpenChange(false);
} }
}; };

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; 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'; import {validatePositiveNumber, validateRequired} from '@/lib/validation';
interface Props { interface Props {
@@ -60,10 +60,11 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
dispatch( dispatch(
updateLiability({ updateLiability({
id: liability.id, id: liability.id,
name: form.name.trim(), data: {
type: form.type as (typeof liabilityTypes)[number], name: form.name.trim(),
balance: balanceNum, type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER',
updatedAt: new Date().toISOString() currentBalance: balanceNum,
}
}) })
); );
onOpenChange(false); onOpenChange(false);
@@ -72,7 +73,7 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
const handleDelete = () => { const handleDelete = () => {
if (!liability) return; if (!liability) return;
if (confirm(`Are you sure you want to delete "${liability.name}"?`)) { if (confirm(`Are you sure you want to delete "${liability.name}"?`)) {
dispatch(removeLiability(liability.id)); dispatch(deleteLiability(liability.id));
onOpenChange(false); onOpenChange(false);
} }
}; };

View File

@@ -3,7 +3,8 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import {useAppDispatch, setUser} from '@/store'; import {useAppDispatch} from '@/store';
import {loginUser} from '@/store/slices/userSlice';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -18,26 +19,33 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop
password: '', password: '',
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
// Mock login - in production this would validate against an API
if (!form.email || !form.password) { if (!form.email || !form.password) {
setError('Please enter your email and password'); setError('Please enter your email and password');
return; return;
} }
// Mock successful login setIsLoading(true);
dispatch(setUser({ try {
id: crypto.randomUUID(), await dispatch(
email: form.email, loginUser({
name: form.email.split('@')[0], email: form.email,
})); password: form.password,
})
).unwrap();
onOpenChange(false); onOpenChange(false);
setForm({email: '', password: ''}); setForm({email: '', password: ''});
} catch (err: any) {
setError(err || 'Login failed. Please check your credentials.');
} finally {
setIsLoading(false);
}
}; };
return ( return (
@@ -76,8 +84,10 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop
{error && <p className="text-sm text-red-400">{error}</p>} {error && <p className="text-sm text-red-400">{error}</p>}
</div> </div>
<DialogFooter className="flex-col gap-2 sm:flex-col"> <DialogFooter className="flex-col gap-2 sm:flex-col">
<Button type="submit" className="w-full">Log in</Button> <Button type="submit" className="w-full" disabled={isLoading}>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToSignUp}> {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 Don't have an account? Sign up
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -3,7 +3,8 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import {useAppDispatch, setUser} from '@/store'; import {useAppDispatch} from '@/store';
import {registerUser} from '@/store/slices/userSlice';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -20,8 +21,9 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
confirmPassword: '', confirmPassword: '',
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@@ -30,20 +32,28 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
return; return;
} }
if (form.password.length < 6) { if (form.password.length < 8) {
setError('Password must be at least 6 characters'); setError('Password must be at least 8 characters');
return; return;
} }
// Mock sign up - in production this would call an API setIsLoading(true);
dispatch(setUser({ try {
id: crypto.randomUUID(), await dispatch(
email: form.email, registerUser({
name: form.name, email: form.email,
})); password: form.password,
name: form.name,
})
).unwrap();
onOpenChange(false); onOpenChange(false);
setForm({name: '', email: '', password: '', confirmPassword: ''}); setForm({name: '', email: '', password: '', confirmPassword: ''});
} catch (err: any) {
setError(err || 'Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
}; };
return ( return (
@@ -109,8 +119,10 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
</p> </p>
</div> </div>
<DialogFooter className="flex-col gap-2 sm:flex-col"> <DialogFooter className="flex-col gap-2 sm:flex-col">
<Button type="submit" className="w-full">Create account</Button> <Button type="submit" className="w-full" disabled={isLoading}>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin}> {isLoading ? 'Creating account...' : 'Create account'}
</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin} disabled={isLoading}>
Already have an account? Log in Already have an account? Log in
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -0,0 +1,71 @@
/**
* Authentication Service
*/
import {apiClient} from './client';
import {tokenStorage} from './token';
export interface RegisterRequest {
email: string;
password: string;
name: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface AuthResponse {
token: string;
user: {
id: string;
email: string;
name: string;
};
}
export interface UserProfile {
id: string;
email: string;
name: string;
createdAt: string;
}
export const authService = {
async register(data: RegisterRequest): Promise<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,110 @@
/**
* API Client
* Base configuration for all API requests
*/
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export interface ApiError {
message: string;
statusCode: number;
error?: string;
}
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private getAuthToken(): string | null {
return localStorage.getItem('auth_token');
}
private async request<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

@@ -1,7 +1,7 @@
import {useState} from 'react'; import {useState} from 'react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button'; 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 {AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer} from 'recharts';
import {format} from 'date-fns'; import {format} from 'date-fns';
import AddAssetDialog from '@/components/dialogs/AddAssetDialog'; import AddAssetDialog from '@/components/dialogs/AddAssetDialog';
@@ -12,7 +12,6 @@ import {formatCurrency, formatPercentage} from '@/lib/formatters';
import {calculateMonthlyChange, calculateYTDGrowth} from '@/lib/calculations'; import {calculateMonthlyChange, calculateYTDGrowth} from '@/lib/calculations';
export default function NetWorthPage() { export default function NetWorthPage() {
const dispatch = useAppDispatch();
const [assetDialogOpen, setAssetDialogOpen] = useState(false); const [assetDialogOpen, setAssetDialogOpen] = useState(false);
const [liabilityDialogOpen, setLiabilityDialogOpen] = useState(false); const [liabilityDialogOpen, setLiabilityDialogOpen] = useState(false);
const [editAssetDialogOpen, setEditAssetDialogOpen] = useState(false); const [editAssetDialogOpen, setEditAssetDialogOpen] = useState(false);
@@ -34,14 +33,8 @@ export default function NetWorthPage() {
const ytdGrowth = calculateYTDGrowth(snapshots); const ytdGrowth = calculateYTDGrowth(snapshots);
const handleRecordSnapshot = () => { const handleRecordSnapshot = () => {
const snapshot = { // TODO: Implement createSnapshot thunk
id: crypto.randomUUID(), console.log('Record snapshot functionality not yet implemented');
date: new Date().toISOString().split('T')[0],
totalAssets,
totalLiabilities,
netWorth
};
dispatch(addSnapshot(snapshot));
}; };
const handleEditAsset = (asset: Asset) => { const handleEditAsset = (asset: Asset) => {

View File

@@ -13,14 +13,15 @@ export type {User, UserState} from './slices/userSlice';
export { export {
setLoading as setNetWorthLoading, setLoading as setNetWorthLoading,
setError as setNetWorthError, setError as setNetWorthError,
addAsset, fetchAssets,
createAsset,
updateAsset, updateAsset,
removeAsset, deleteAsset,
addLiability, fetchLiabilities,
createLiability,
updateLiability, updateLiability,
removeLiability, deleteLiability,
addSnapshot, fetchSnapshots
setSnapshots
} from './slices/netWorthSlice'; } from './slices/netWorthSlice';
export type {Asset, Liability, NetWorthSnapshot, NetWorthState} 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 { export interface IncomeSource {
id: string; id: string;
@@ -47,162 +48,77 @@ const defaultCategories = {
expense: ['Housing', 'Utilities', 'Transportation', 'Food', 'Insurance', 'Healthcare', 'Subscriptions', 'Entertainment', 'Shopping', 'Savings', 'Other'] 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 = { const initialState: CashflowState = {
incomeSources: mockIncomeSources, incomeSources: [],
expenses: mockExpenses, expenses: [],
transactions: mockTransactions, transactions: [],
categories: defaultCategories, categories: defaultCategories,
isLoading: false, isLoading: false,
error: null error: null
}; };
// Helper mappers
const mapApiIncomeToIncome = (apiIncome: ApiIncome): IncomeSource => ({
id: apiIncome.id,
name: apiIncome.name,
amount: apiIncome.amount,
frequency: apiIncome.frequency.toLowerCase() as IncomeSource['frequency'],
category: 'Income',
nextDate: new Date().toISOString(),
isActive: true,
createdAt: apiIncome.createdAt || new Date().toISOString(),
});
const mapApiExpenseToExpense = (apiExpense: ApiExpense): Expense => ({
id: apiExpense.id,
name: apiExpense.name,
amount: apiExpense.amount,
frequency: apiExpense.frequency.toLowerCase() as Expense['frequency'],
category: apiExpense.category || 'Other',
nextDate: new Date().toISOString(),
isActive: true,
isEssential: apiExpense.isEssential || false,
createdAt: apiExpense.createdAt || new Date().toISOString(),
});
const mapApiTransactionToTransaction = (apiTransaction: ApiTransaction): Transaction => ({
id: apiTransaction.id,
type: apiTransaction.type.toLowerCase() as Transaction['type'],
name: apiTransaction.description,
amount: apiTransaction.amount,
category: apiTransaction.category || 'Other',
date: apiTransaction.date,
note: apiTransaction.notes,
});
// Async thunks
export const fetchIncomeSources = createAsyncThunk('cashflow/fetchIncomeSources', async (_, {rejectWithValue}) => {
try {
const response = await incomeService.getAll();
return response.incomeSources.map(mapApiIncomeToIncome);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch income sources');
}
});
export const fetchExpenses = createAsyncThunk('cashflow/fetchExpenses', async (_, {rejectWithValue}) => {
try {
const response = await expenseService.getAll();
return response.expenses.map(mapApiExpenseToExpense);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch expenses');
}
});
export const fetchTransactions = createAsyncThunk('cashflow/fetchTransactions', async (_, {rejectWithValue}) => {
try {
const response = await transactionService.getAll();
return response.transactions.map(mapApiTransactionToTransaction);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch transactions');
}
});
const cashflowSlice = createSlice({ const cashflowSlice = createSlice({
name: 'cashflow', name: 'cashflow',
initialState, initialState,
@@ -242,7 +158,50 @@ const cashflowSlice = createSlice({
removeTransaction: (state, action: PayloadAction<string>) => { removeTransaction: (state, action: PayloadAction<string>) => {
state.transactions = state.transactions.filter(t => t.id !== action.payload); 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 { export const {

View File

@@ -50,104 +50,10 @@ const defaultCategories: DebtCategory[] = [
{id: 'other', name: 'Other', color: '#6b7280', createdAt: new Date().toISOString()} {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 = { const initialState: DebtsState = {
categories: defaultCategories, categories: defaultCategories,
accounts: mockAccounts, accounts: [],
payments: mockPayments, payments: [],
isLoading: false, isLoading: false,
error: null error: null
}; };

View File

@@ -42,129 +42,9 @@ export interface InvoicesState {
error: string | null; error: string | null;
} }
// Mock data for development
const mockClients: Client[] = [
{
id: 'c1',
name: 'Acme Corp',
email: 'billing@acme.com',
phone: '555-0100',
company: 'Acme Corporation',
address: '123 Business Ave, Suite 400, San Francisco, CA 94102',
createdAt: '2024-01-10'
},
{
id: 'c2',
name: 'TechStart Inc',
email: 'accounts@techstart.io',
phone: '555-0200',
company: 'TechStart Inc',
address: '456 Innovation Blvd, Austin, TX 78701',
createdAt: '2024-02-15'
},
{
id: 'c3',
name: 'Sarah Mitchell',
email: 'sarah@mitchell.design',
company: 'Mitchell Design Studio',
createdAt: '2024-03-22'
},
{
id: 'c4',
name: 'Global Media LLC',
email: 'finance@globalmedia.com',
phone: '555-0400',
company: 'Global Media LLC',
address: '789 Media Row, New York, NY 10001',
createdAt: '2024-04-08'
}
];
const mockInvoices: Invoice[] = [
{
id: 'inv1',
invoiceNumber: 'INV-2024-001',
clientId: 'c1',
status: 'paid',
issueDate: '2024-10-01',
dueDate: '2024-10-31',
lineItems: [
{id: 'li1', description: 'Web Development - October', quantity: 80, unitPrice: 150, total: 12000},
{id: 'li2', description: 'Hosting & Maintenance', quantity: 1, unitPrice: 500, total: 500}
],
subtotal: 12500,
tax: 0,
total: 12500,
createdAt: '2024-10-01',
updatedAt: '2024-10-15'
},
{
id: 'inv2',
invoiceNumber: 'INV-2024-002',
clientId: 'c2',
status: 'paid',
issueDate: '2024-10-15',
dueDate: '2024-11-14',
lineItems: [{id: 'li3', description: 'Mobile App Development', quantity: 120, unitPrice: 175, total: 21000}],
subtotal: 21000,
tax: 0,
total: 21000,
createdAt: '2024-10-15',
updatedAt: '2024-11-10'
},
{
id: 'inv3',
invoiceNumber: 'INV-2024-003',
clientId: 'c1',
status: 'sent',
issueDate: '2024-11-01',
dueDate: '2024-12-01',
lineItems: [
{id: 'li4', description: 'Web Development - November', quantity: 60, unitPrice: 150, total: 9000},
{id: 'li5', description: 'API Integration', quantity: 20, unitPrice: 175, total: 3500}
],
subtotal: 12500,
tax: 0,
total: 12500,
createdAt: '2024-11-01',
updatedAt: '2024-11-01'
},
{
id: 'inv4',
invoiceNumber: 'INV-2024-004',
clientId: 'c3',
status: 'overdue',
issueDate: '2024-10-20',
dueDate: '2024-11-20',
lineItems: [{id: 'li6', description: 'Brand Identity Design', quantity: 1, unitPrice: 4500, total: 4500}],
subtotal: 4500,
tax: 0,
total: 4500,
createdAt: '2024-10-20',
updatedAt: '2024-10-20'
},
{
id: 'inv5',
invoiceNumber: 'INV-2024-005',
clientId: 'c4',
status: 'draft',
issueDate: '2024-12-01',
dueDate: '2024-12-31',
lineItems: [
{id: 'li7', description: 'Video Production', quantity: 5, unitPrice: 2000, total: 10000},
{id: 'li8', description: 'Motion Graphics', quantity: 10, unitPrice: 500, total: 5000}
],
subtotal: 15000,
tax: 0,
total: 15000,
createdAt: '2024-12-01',
updatedAt: '2024-12-01'
}
];
const initialState: InvoicesState = { const initialState: InvoicesState = {
clients: mockClients, clients: [],
invoices: mockInvoices, invoices: [],
isLoading: false, isLoading: false,
error: null error: null
}; };

View File

@@ -1,4 +1,15 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit';
import {
assetService,
liabilityService,
snapshotService,
type Asset as ApiAsset,
type Liability as ApiLiability,
type CreateAssetRequest,
type UpdateAssetRequest,
type CreateLiabilityRequest,
type UpdateLiabilityRequest,
} from '@/lib/api/networth.service';
export interface Asset { export interface Asset {
id: string; id: string;
@@ -32,39 +43,118 @@ export interface NetWorthState {
error: string | null; error: string | null;
} }
// Mock data for development
const mockAssets: Asset[] = [
{id: 'a1', name: 'Chase Checking', type: 'cash', value: 12500, updatedAt: '2024-12-01'},
{id: 'a2', name: 'Ally Savings', type: 'cash', value: 35000, updatedAt: '2024-12-01'},
{id: 'a3', name: 'Fidelity 401k', type: 'investment', value: 145000, updatedAt: '2024-12-01'},
{id: 'a4', name: 'Vanguard Brokerage', type: 'investment', value: 52000, updatedAt: '2024-12-01'},
{id: 'a5', name: 'Primary Residence', type: 'property', value: 425000, updatedAt: '2024-12-01'},
{id: 'a6', name: '2021 Tesla Model 3', type: 'vehicle', value: 28000, updatedAt: '2024-12-01'}
];
const mockLiabilities: Liability[] = [
{id: 'l1', name: 'Mortgage', type: 'mortgage', balance: 320000, updatedAt: '2024-12-01'},
{id: 'l2', name: 'Auto Loan', type: 'loan', balance: 15000, updatedAt: '2024-12-01'},
{id: 'l3', name: 'Student Loans', type: 'loan', balance: 28000, updatedAt: '2024-12-01'}
];
const mockSnapshots: NetWorthSnapshot[] = [
{id: 's1', date: '2024-07-01', totalAssets: 650000, totalLiabilities: 380000, netWorth: 270000},
{id: 's2', date: '2024-08-01', totalAssets: 665000, totalLiabilities: 375000, netWorth: 290000},
{id: 's3', date: '2024-09-01', totalAssets: 680000, totalLiabilities: 370000, netWorth: 310000},
{id: 's4', date: '2024-10-01', totalAssets: 685000, totalLiabilities: 368000, netWorth: 317000},
{id: 's5', date: '2024-11-01', totalAssets: 692000, totalLiabilities: 365000, netWorth: 327000},
{id: 's6', date: '2024-12-01', totalAssets: 697500, totalLiabilities: 363000, netWorth: 334500}
];
const initialState: NetWorthState = { const initialState: NetWorthState = {
assets: mockAssets, assets: [],
liabilities: mockLiabilities, liabilities: [],
snapshots: mockSnapshots, snapshots: [],
isLoading: false, isLoading: false,
error: null error: null
}; };
// Helper functions to map between API and UI types
const mapApiAssetToAsset = (apiAsset: ApiAsset): Asset => ({
id: apiAsset.id,
name: apiAsset.name,
type: apiAsset.type.toLowerCase() as Asset['type'],
value: apiAsset.value,
updatedAt: apiAsset.updatedAt || new Date().toISOString(),
});
const mapApiLiabilityToLiability = (apiLiability: ApiLiability): Liability => ({
id: apiLiability.id,
name: apiLiability.name,
type: apiLiability.type.toLowerCase() as Liability['type'],
balance: apiLiability.currentBalance,
updatedAt: apiLiability.updatedAt || new Date().toISOString(),
});
// Async thunks for assets
export const fetchAssets = createAsyncThunk('netWorth/fetchAssets', async (_, {rejectWithValue}) => {
try {
const response = await assetService.getAll();
return response.assets.map(mapApiAssetToAsset);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch assets');
}
});
export const createAsset = createAsyncThunk('netWorth/createAsset', async (data: CreateAssetRequest, {rejectWithValue}) => {
try {
const response = await assetService.create(data);
return mapApiAssetToAsset(response.asset);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to create asset');
}
});
export const updateAsset = createAsyncThunk('netWorth/updateAsset', async ({id, data}: {id: string; data: UpdateAssetRequest}, {rejectWithValue}) => {
try {
const response = await assetService.update(id, data);
return mapApiAssetToAsset(response.asset);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to update asset');
}
});
export const deleteAsset = createAsyncThunk('netWorth/deleteAsset', async (id: string, {rejectWithValue}) => {
try {
await assetService.delete(id);
return id;
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to delete asset');
}
});
// Async thunks for liabilities
export const fetchLiabilities = createAsyncThunk('netWorth/fetchLiabilities', async (_, {rejectWithValue}) => {
try {
const response = await liabilityService.getAll();
return response.liabilities.map(mapApiLiabilityToLiability);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch liabilities');
}
});
export const createLiability = createAsyncThunk('netWorth/createLiability', async (data: CreateLiabilityRequest, {rejectWithValue}) => {
try {
const response = await liabilityService.create(data);
return mapApiLiabilityToLiability(response.liability);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to create liability');
}
});
export const updateLiability = createAsyncThunk(
'netWorth/updateLiability',
async ({id, data}: {id: string; data: UpdateLiabilityRequest}, {rejectWithValue}) => {
try {
const response = await liabilityService.update(id, data);
return mapApiLiabilityToLiability(response.liability);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to update liability');
}
}
);
export const deleteLiability = createAsyncThunk('netWorth/deleteLiability', async (id: string, {rejectWithValue}) => {
try {
await liabilityService.delete(id);
return id;
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to delete liability');
}
});
// Async thunks for snapshots
export const fetchSnapshots = createAsyncThunk('netWorth/fetchSnapshots', async (_, {rejectWithValue}) => {
try {
const response = await snapshotService.getAll();
return response.snapshots;
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch snapshots');
}
});
const netWorthSlice = createSlice({ const netWorthSlice = createSlice({
name: 'netWorth', name: 'netWorth',
initialState, initialState,
@@ -75,36 +165,84 @@ const netWorthSlice = createSlice({
setError: (state, action: PayloadAction<string | null>) => { setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload; state.error = action.payload;
}, },
addAsset: (state, action: PayloadAction<Asset>) => { },
extraReducers: builder => {
// Fetch assets
builder.addCase(fetchAssets.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchAssets.fulfilled, (state, action) => {
state.isLoading = false;
state.assets = action.payload;
});
builder.addCase(fetchAssets.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Create asset
builder.addCase(createAsset.fulfilled, (state, action) => {
state.assets.push(action.payload); state.assets.push(action.payload);
}, });
updateAsset: (state, action: PayloadAction<Asset>) => {
// Update asset
builder.addCase(updateAsset.fulfilled, (state, action) => {
const index = state.assets.findIndex(a => a.id === action.payload.id); const index = state.assets.findIndex(a => a.id === action.payload.id);
if (index !== -1) state.assets[index] = action.payload; if (index !== -1) state.assets[index] = action.payload;
}, });
removeAsset: (state, action: PayloadAction<string>) => {
// Delete asset
builder.addCase(deleteAsset.fulfilled, (state, action) => {
state.assets = state.assets.filter(a => a.id !== action.payload); state.assets = state.assets.filter(a => a.id !== action.payload);
}, });
addLiability: (state, action: PayloadAction<Liability>) => {
// Fetch liabilities
builder.addCase(fetchLiabilities.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchLiabilities.fulfilled, (state, action) => {
state.isLoading = false;
state.liabilities = action.payload;
});
builder.addCase(fetchLiabilities.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Create liability
builder.addCase(createLiability.fulfilled, (state, action) => {
state.liabilities.push(action.payload); state.liabilities.push(action.payload);
}, });
updateLiability: (state, action: PayloadAction<Liability>) => {
// Update liability
builder.addCase(updateLiability.fulfilled, (state, action) => {
const index = state.liabilities.findIndex(l => l.id === action.payload.id); const index = state.liabilities.findIndex(l => l.id === action.payload.id);
if (index !== -1) state.liabilities[index] = action.payload; if (index !== -1) state.liabilities[index] = action.payload;
}, });
removeLiability: (state, action: PayloadAction<string>) => {
// Delete liability
builder.addCase(deleteLiability.fulfilled, (state, action) => {
state.liabilities = state.liabilities.filter(l => l.id !== action.payload); state.liabilities = state.liabilities.filter(l => l.id !== action.payload);
}, });
addSnapshot: (state, action: PayloadAction<NetWorthSnapshot>) => {
state.snapshots.push(action.payload); // Fetch snapshots
}, builder.addCase(fetchSnapshots.pending, state => {
setSnapshots: (state, action: PayloadAction<NetWorthSnapshot[]>) => { state.isLoading = true;
state.error = null;
});
builder.addCase(fetchSnapshots.fulfilled, (state, action) => {
state.isLoading = false;
state.snapshots = action.payload; state.snapshots = action.payload;
} });
} builder.addCase(fetchSnapshots.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
}); });
export const {setLoading, setError, addAsset, updateAsset, removeAsset, addLiability, updateLiability, removeLiability, addSnapshot, setSnapshots} = export const {setLoading, setError} = netWorthSlice.actions;
netWorthSlice.actions;
export default netWorthSlice.reducer; export default netWorthSlice.reducer;

View File

@@ -1,4 +1,5 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit';
import {authService, type RegisterRequest, type LoginRequest} from '@/lib/api/auth.service';
export interface User { export interface User {
id: string; id: string;
@@ -20,6 +21,40 @@ const initialState: UserState = {
error: null error: null
}; };
// Async thunks
export const registerUser = createAsyncThunk('user/register', async (data: RegisterRequest, {rejectWithValue}) => {
try {
const response = await authService.register(data);
return response.user;
} catch (error: any) {
return rejectWithValue(error.message || 'Registration failed');
}
});
export const loginUser = createAsyncThunk('user/login', async (data: LoginRequest, {rejectWithValue}) => {
try {
const response = await authService.login(data);
return response.user;
} catch (error: any) {
return rejectWithValue(error.message || 'Login failed');
}
});
export const loadUserFromStorage = createAsyncThunk('user/loadFromStorage', async (_, {rejectWithValue}) => {
try {
const user = authService.getCurrentUser();
if (!user) {
return rejectWithValue('No user found');
}
// Verify token is still valid by fetching profile
await authService.getProfile();
return user;
} catch (error: any) {
authService.logout();
return rejectWithValue(error.message || 'Session expired');
}
});
const userSlice = createSlice({ const userSlice = createSlice({
name: 'user', name: 'user',
initialState, initialState,
@@ -33,6 +68,7 @@ const userSlice = createSlice({
state.error = null; state.error = null;
}, },
clearUser: state => { clearUser: state => {
authService.logout();
state.currentUser = null; state.currentUser = null;
state.isAuthenticated = false; state.isAuthenticated = false;
state.error = null; state.error = null;
@@ -41,6 +77,54 @@ const userSlice = createSlice({
state.error = action.payload; state.error = action.payload;
state.isLoading = false; state.isLoading = false;
} }
},
extraReducers: builder => {
// Register
builder.addCase(registerUser.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(registerUser.fulfilled, (state, action) => {
state.isLoading = false;
state.currentUser = action.payload;
state.isAuthenticated = true;
state.error = null;
});
builder.addCase(registerUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Login
builder.addCase(loginUser.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(loginUser.fulfilled, (state, action) => {
state.isLoading = false;
state.currentUser = action.payload;
state.isAuthenticated = true;
state.error = null;
});
builder.addCase(loginUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Load from storage
builder.addCase(loadUserFromStorage.pending, state => {
state.isLoading = true;
});
builder.addCase(loadUserFromStorage.fulfilled, (state, action) => {
state.isLoading = false;
state.currentUser = action.payload;
state.isAuthenticated = true;
});
builder.addCase(loadUserFromStorage.rejected, state => {
state.isLoading = false;
state.currentUser = null;
state.isAuthenticated = false;
});
} }
}); });

305
package-lock.json generated Normal file
View File

@@ -0,0 +1,305 @@
{
"name": "personal-finances",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "personal-finances",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"concurrently": "^9.2.1"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}