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

View File

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

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 {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
});
}
return DatabaseConnection.instance;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,33 +11,33 @@ const prisma = DatabaseConnection.getInstance();
export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWorthSnapshot> {
async findById(id: string): Promise<NetWorthSnapshot | null> {
return prisma.netWorthSnapshot.findUnique({
where: {id},
where: {id}
});
}
async findAllByUser(userId: string): Promise<NetWorthSnapshot[]> {
return prisma.netWorthSnapshot.findMany({
where: {userId},
orderBy: {date: 'desc'},
orderBy: {date: 'desc'}
});
}
async create(data: Prisma.NetWorthSnapshotCreateInput): Promise<NetWorthSnapshot> {
return prisma.netWorthSnapshot.create({
data,
data
});
}
async update(id: string, data: Prisma.NetWorthSnapshotUpdateInput): Promise<NetWorthSnapshot> {
return prisma.netWorthSnapshot.update({
where: {id},
data,
data
});
}
async delete(id: string): Promise<void> {
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> {
return prisma.netWorthSnapshot.findFirst({
where: {userId},
orderBy: {date: 'desc'},
orderBy: {date: 'desc'}
});
}
@@ -60,10 +60,10 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
userId,
date: {
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({
where: {
userId,
date,
},
date
}
});
return count > 0;
@@ -88,7 +88,7 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
const snapshots = await prisma.netWorthSnapshot.findMany({
where: {userId},
orderBy: {date: 'desc'},
take: limit,
take: limit
});
const stats = [];
@@ -96,14 +96,13 @@ export class NetWorthSnapshotRepository implements IUserScopedRepository<NetWort
const current = snapshots[i];
const previous = snapshots[i + 1];
const growthAmount = current.netWorth - previous.netWorth;
const growthPercent =
previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0;
const growthPercent = previous.netWorth !== 0 ? (growthAmount / previous.netWorth) * 100 : 0;
stats.push({
date: current.date,
netWorth: current.netWorth,
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,
createdAt: true,
updatedAt: true,
password: false, // Never return password
},
password: false // Never return password
}
}) as unknown as User[];
}
@@ -36,7 +36,7 @@ export class UserRepository implements IRepository<User> {
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
return prisma.user.update({
where: {id},
data,
data
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,8 +64,8 @@ export class DebtAccountService {
dueDate: data.dueDate,
notes: data.notes,
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: 'Mortgages', description: 'Home mortgages', color: '#f59e0b'},
{name: 'Personal Loans', description: 'Personal loan debts', color: '#8b5cf6'},
{name: 'Other', description: 'Other debt types', color: '#6b7280'},
{name: 'Other', description: 'Other debt types', color: '#6b7280'}
];
const categories: DebtCategory[] = [];
@@ -43,8 +43,8 @@ export class DebtCategoryService {
description: category.description,
color: category.color,
user: {
connect: {id: userId},
},
connect: {id: userId}
}
});
categories.push(created);
}
@@ -69,8 +69,8 @@ export class DebtCategoryService {
description: data.description,
color: data.color,
user: {
connect: {id: userId},
},
connect: {id: userId}
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ export class PasswordService {
return {
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
*.local
# Environment variables
.env
.env.local
# Editor directories and files
.vscode/*
!.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 {useAppSelector} from '@/store';
import {useAppSelector, useAppDispatch} from '@/store';
import {loadUserFromStorage} from '@/store/slices/userSlice';
import {fetchAssets, fetchLiabilities, fetchSnapshots} from '@/store/slices/netWorthSlice';
import {fetchIncomeSources, fetchExpenses, fetchTransactions} from '@/store/slices/cashflowSlice';
import Layout from '@/components/Layout';
import ProtectedRoute from '@/components/ProtectedRoute';
@@ -20,7 +23,30 @@ const PageLoader = () => (
);
function AppRoutes() {
const {isAuthenticated} = useAppSelector(state => state.user);
const dispatch = useAppDispatch();
const {isAuthenticated, isLoading} = useAppSelector(state => state.user);
// Load user from storage on app start
useEffect(() => {
dispatch(loadUserFromStorage());
}, [dispatch]);
// Fetch all data when user is authenticated
useEffect(() => {
if (isAuthenticated) {
dispatch(fetchAssets());
dispatch(fetchLiabilities());
dispatch(fetchSnapshots());
dispatch(fetchIncomeSources());
dispatch(fetchExpenses());
dispatch(fetchTransactions());
}
}, [isAuthenticated, dispatch]);
// Show loading while checking authentication
if (isLoading) {
return <PageLoader />;
}
return (
<Routes>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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"
}
}
}
}