mirror of https://github.com/ghostfolio/ghostfolio
22 changed files with 2372 additions and 22 deletions
@ -0,0 +1,89 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { JwtOrApiKeyAuthGuard } from '@ghostfolio/api/guards/jwt-or-api-key-auth.guard'; |
|||
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos'; |
|||
import { BudgetResponse, BudgetsResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Put, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
|
|||
import { BudgetsService } from './budgets.service'; |
|||
|
|||
@Controller('budgets') |
|||
export class BudgetsController { |
|||
public constructor( |
|||
private readonly budgetsService: BudgetsService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@HasPermission(permissions.createBudget) |
|||
@Post() |
|||
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) |
|||
public async createBudget( |
|||
@Body() data: CreateBudgetDto |
|||
): Promise<BudgetResponse> { |
|||
return this.budgetsService.createBudget({ |
|||
data, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Delete(':id') |
|||
@HasPermission(permissions.deleteBudget) |
|||
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) |
|||
public async deleteBudget(@Param('id') id: string) { |
|||
return this.budgetsService.deleteBudget({ |
|||
id, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readBudgets) |
|||
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) |
|||
public async getBudgets( |
|||
@Query('month') month: string |
|||
): Promise<BudgetsResponse> { |
|||
return this.budgetsService.getBudgets({ |
|||
month, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Get(':id') |
|||
@HasPermission(permissions.readBudgets) |
|||
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) |
|||
public async getBudget(@Param('id') id: string): Promise<BudgetResponse> { |
|||
return this.budgetsService.getBudget({ |
|||
id, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Put(':id') |
|||
@HasPermission(permissions.updateBudget) |
|||
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) |
|||
public async updateBudget( |
|||
@Param('id') id: string, |
|||
@Body() data: UpdateBudgetDto |
|||
): Promise<BudgetResponse> { |
|||
return this.budgetsService.updateBudget({ |
|||
data, |
|||
id, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { BudgetsController } from './budgets.controller'; |
|||
import { BudgetsService } from './budgets.service'; |
|||
|
|||
@Module({ |
|||
controllers: [BudgetsController], |
|||
imports: [PrismaModule], |
|||
providers: [BudgetsService] |
|||
}) |
|||
export class BudgetsModule {} |
|||
@ -0,0 +1,285 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
|
|||
import { ConflictException, ForbiddenException } from '@nestjs/common'; |
|||
|
|||
import { BudgetsService } from './budgets.service'; |
|||
|
|||
describe('BudgetsService', () => { |
|||
const userId = 'user-1'; |
|||
const categoryId = 'category-1'; |
|||
const budgetId = 'budget-1'; |
|||
const createdAt = new Date('2026-06-23T00:00:00.000Z'); |
|||
const updatedAt = new Date('2026-06-23T00:00:00.000Z'); |
|||
|
|||
let budgetsService: BudgetsService; |
|||
let prismaService: { |
|||
budget: { |
|||
create: jest.Mock; |
|||
delete: jest.Mock; |
|||
findFirst: jest.Mock; |
|||
findMany: jest.Mock; |
|||
update: jest.Mock; |
|||
}; |
|||
expense: { |
|||
groupBy: jest.Mock; |
|||
}; |
|||
expenseCategory: { |
|||
findFirst: jest.Mock; |
|||
}; |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
prismaService = { |
|||
budget: { |
|||
create: jest.fn(), |
|||
delete: jest.fn(), |
|||
findFirst: jest.fn(), |
|||
findMany: jest.fn(), |
|||
update: jest.fn() |
|||
}, |
|||
expense: { |
|||
groupBy: jest.fn() |
|||
}, |
|||
expenseCategory: { |
|||
findFirst: jest.fn() |
|||
} |
|||
}; |
|||
|
|||
budgetsService = new BudgetsService( |
|||
prismaService as unknown as PrismaService |
|||
); |
|||
}); |
|||
|
|||
it('lists budgets for a month and includes spent and remaining amounts', async () => { |
|||
prismaService.budget.findMany.mockResolvedValue([ |
|||
{ |
|||
amount: 500, |
|||
category: { |
|||
color: '#0055aa', |
|||
createdAt, |
|||
id: categoryId, |
|||
name: 'Groceries', |
|||
updatedAt |
|||
}, |
|||
categoryId, |
|||
createdAt, |
|||
currency: 'USD', |
|||
id: budgetId, |
|||
month: new Date('2026-06-01T00:00:00.000Z'), |
|||
updatedAt, |
|||
userId |
|||
} |
|||
]); |
|||
prismaService.expense.groupBy.mockResolvedValue([ |
|||
{ |
|||
_sum: { amount: 125 }, |
|||
categoryId |
|||
} |
|||
]); |
|||
|
|||
const response = await budgetsService.getBudgets({ |
|||
month: '2026-06', |
|||
userId |
|||
}); |
|||
|
|||
expect(prismaService.budget.findMany).toHaveBeenCalledWith({ |
|||
include: { category: true }, |
|||
orderBy: { category: { name: 'asc' } }, |
|||
where: { |
|||
month: new Date('2026-06-01T00:00:00.000Z'), |
|||
userId |
|||
} |
|||
}); |
|||
expect(prismaService.expense.groupBy).toHaveBeenCalledWith({ |
|||
by: ['categoryId'], |
|||
where: { |
|||
categoryId: { in: [categoryId] }, |
|||
date: { |
|||
gte: new Date('2026-06-01T00:00:00.000Z'), |
|||
lt: new Date('2026-07-01T00:00:00.000Z') |
|||
}, |
|||
userId |
|||
}, |
|||
_sum: { amount: true } |
|||
}); |
|||
expect(response).toEqual({ |
|||
budgets: [ |
|||
{ |
|||
amount: 500, |
|||
category: { |
|||
color: '#0055aa', |
|||
createdAt, |
|||
id: categoryId, |
|||
name: 'Groceries', |
|||
updatedAt |
|||
}, |
|||
categoryId, |
|||
createdAt, |
|||
currency: 'USD', |
|||
id: budgetId, |
|||
month: '2026-06', |
|||
remaining: 375, |
|||
spent: 125, |
|||
updatedAt |
|||
} |
|||
], |
|||
totalBudgeted: 500, |
|||
totalRemaining: 375, |
|||
totalSpent: 125 |
|||
}); |
|||
}); |
|||
|
|||
it('creates a budget for a category owned by the current user', async () => { |
|||
prismaService.expenseCategory.findFirst.mockResolvedValue({ |
|||
id: categoryId, |
|||
userId |
|||
}); |
|||
prismaService.budget.findFirst.mockResolvedValue(null); |
|||
prismaService.budget.create.mockResolvedValue({ |
|||
amount: 250, |
|||
category: { |
|||
createdAt, |
|||
id: categoryId, |
|||
name: 'Transport', |
|||
updatedAt |
|||
}, |
|||
categoryId, |
|||
createdAt, |
|||
currency: 'USD', |
|||
id: budgetId, |
|||
month: new Date('2026-06-01T00:00:00.000Z'), |
|||
updatedAt, |
|||
userId |
|||
}); |
|||
|
|||
const response = await budgetsService.createBudget({ |
|||
data: { |
|||
amount: 250, |
|||
categoryId, |
|||
currency: 'USD', |
|||
month: '2026-06' |
|||
}, |
|||
userId |
|||
}); |
|||
|
|||
expect(prismaService.budget.create).toHaveBeenCalledWith({ |
|||
data: { |
|||
amount: 250, |
|||
category: { connect: { id: categoryId } }, |
|||
currency: 'USD', |
|||
month: new Date('2026-06-01T00:00:00.000Z'), |
|||
user: { connect: { id: userId } } |
|||
}, |
|||
include: { category: true } |
|||
}); |
|||
expect(response.remaining).toBe(250); |
|||
expect(response.spent).toBe(0); |
|||
}); |
|||
|
|||
it('rejects duplicate budgets for the same category and month', async () => { |
|||
prismaService.expenseCategory.findFirst.mockResolvedValue({ |
|||
id: categoryId, |
|||
userId |
|||
}); |
|||
prismaService.budget.findFirst.mockResolvedValue({ |
|||
id: budgetId |
|||
}); |
|||
|
|||
await expect( |
|||
budgetsService.createBudget({ |
|||
data: { |
|||
amount: 250, |
|||
categoryId, |
|||
currency: 'USD', |
|||
month: '2026-06' |
|||
}, |
|||
userId |
|||
}) |
|||
).rejects.toBeInstanceOf(ConflictException); |
|||
}); |
|||
|
|||
it('updates only a budget owned by the current user', async () => { |
|||
prismaService.expenseCategory.findFirst.mockResolvedValue({ |
|||
id: categoryId, |
|||
userId |
|||
}); |
|||
prismaService.budget.findFirst.mockResolvedValue({ |
|||
id: budgetId, |
|||
userId |
|||
}); |
|||
prismaService.budget.update.mockResolvedValue({ |
|||
amount: 300, |
|||
category: { |
|||
createdAt, |
|||
id: categoryId, |
|||
name: 'Transport', |
|||
updatedAt |
|||
}, |
|||
categoryId, |
|||
createdAt, |
|||
currency: 'USD', |
|||
id: budgetId, |
|||
month: new Date('2026-06-01T00:00:00.000Z'), |
|||
updatedAt, |
|||
userId |
|||
}); |
|||
|
|||
const response = await budgetsService.updateBudget({ |
|||
data: { |
|||
amount: 300, |
|||
categoryId, |
|||
currency: 'USD', |
|||
id: budgetId, |
|||
month: '2026-06' |
|||
}, |
|||
id: budgetId, |
|||
userId |
|||
}); |
|||
|
|||
expect(prismaService.budget.update).toHaveBeenCalledWith({ |
|||
data: { |
|||
amount: 300, |
|||
category: { connect: { id: categoryId } }, |
|||
currency: 'USD', |
|||
month: new Date('2026-06-01T00:00:00.000Z') |
|||
}, |
|||
include: { category: true }, |
|||
where: { id: budgetId } |
|||
}); |
|||
expect(response.amount).toBe(300); |
|||
}); |
|||
|
|||
it('throws when updating a budget not owned by the current user', async () => { |
|||
prismaService.budget.findFirst.mockResolvedValue(null); |
|||
|
|||
await expect( |
|||
budgetsService.updateBudget({ |
|||
data: { |
|||
amount: 300, |
|||
categoryId, |
|||
currency: 'USD', |
|||
id: budgetId, |
|||
month: '2026-06' |
|||
}, |
|||
id: budgetId, |
|||
userId |
|||
}) |
|||
).rejects.toBeInstanceOf(ForbiddenException); |
|||
}); |
|||
|
|||
it('deletes only a budget owned by the current user', async () => { |
|||
prismaService.budget.findFirst.mockResolvedValue({ |
|||
id: budgetId, |
|||
userId |
|||
}); |
|||
prismaService.budget.delete.mockResolvedValue({ |
|||
id: budgetId |
|||
}); |
|||
|
|||
await budgetsService.deleteBudget({ id: budgetId, userId }); |
|||
|
|||
expect(prismaService.budget.delete).toHaveBeenCalledWith({ |
|||
where: { id: budgetId } |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,290 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos'; |
|||
import { BudgetResponse, BudgetsResponse } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { |
|||
ConflictException, |
|||
ForbiddenException, |
|||
Injectable |
|||
} from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class BudgetsService { |
|||
public constructor(private readonly prismaService: PrismaService) {} |
|||
|
|||
public async createBudget({ |
|||
data, |
|||
userId |
|||
}: { |
|||
data: CreateBudgetDto; |
|||
userId: string; |
|||
}): Promise<BudgetResponse> { |
|||
await this.validateCategoryOwnership({ |
|||
categoryId: data.categoryId, |
|||
userId |
|||
}); |
|||
|
|||
const month = this.parseBudgetMonth(data.month); |
|||
|
|||
const existingBudget = await this.prismaService.budget.findFirst({ |
|||
where: { |
|||
categoryId: data.categoryId, |
|||
month, |
|||
userId |
|||
} |
|||
}); |
|||
|
|||
if (existingBudget) { |
|||
throw new ConflictException(); |
|||
} |
|||
|
|||
const budget = await this.prismaService.budget.create({ |
|||
data: { |
|||
amount: data.amount, |
|||
category: { connect: { id: data.categoryId } }, |
|||
currency: data.currency, |
|||
month, |
|||
user: { connect: { id: userId } } |
|||
}, |
|||
include: { category: true } |
|||
}); |
|||
|
|||
return this.toBudgetResponse({ budget, spent: 0 }); |
|||
} |
|||
|
|||
public async deleteBudget({ id, userId }: { id: string; userId: string }) { |
|||
await this.validateBudgetOwnership({ id, userId }); |
|||
|
|||
return this.prismaService.budget.delete({ |
|||
where: { id } |
|||
}); |
|||
} |
|||
|
|||
public async getBudget({ |
|||
id, |
|||
userId |
|||
}: { |
|||
id: string; |
|||
userId: string; |
|||
}): Promise<BudgetResponse> { |
|||
const budget = await this.prismaService.budget.findFirst({ |
|||
include: { category: true }, |
|||
where: { id, userId } |
|||
}); |
|||
|
|||
if (!budget) { |
|||
throw new ForbiddenException(); |
|||
} |
|||
|
|||
const spent = await this.getSpentForBudget({ |
|||
categoryId: budget.categoryId, |
|||
month: budget.month, |
|||
userId |
|||
}); |
|||
|
|||
return this.toBudgetResponse({ budget, spent }); |
|||
} |
|||
|
|||
public async getBudgets({ |
|||
month, |
|||
userId |
|||
}: { |
|||
month: string; |
|||
userId: string; |
|||
}): Promise<BudgetsResponse> { |
|||
const budgetMonth = this.parseBudgetMonth(month); |
|||
const nextMonth = this.getNextMonth(budgetMonth); |
|||
|
|||
const budgets = await this.prismaService.budget.findMany({ |
|||
include: { category: true }, |
|||
orderBy: { category: { name: 'asc' } }, |
|||
where: { |
|||
month: budgetMonth, |
|||
userId |
|||
} |
|||
}); |
|||
|
|||
const categoryIds = budgets.map(({ categoryId }) => categoryId); |
|||
const expenseSums = categoryIds.length |
|||
? await this.prismaService.expense.groupBy({ |
|||
by: ['categoryId'], |
|||
where: { |
|||
categoryId: { in: categoryIds }, |
|||
date: { |
|||
gte: budgetMonth, |
|||
lt: nextMonth |
|||
}, |
|||
userId |
|||
}, |
|||
_sum: { amount: true } |
|||
}) |
|||
: []; |
|||
|
|||
const spentByCategoryId = new Map( |
|||
expenseSums.map(({ _sum, categoryId }) => { |
|||
return [categoryId, _sum.amount ?? 0]; |
|||
}) |
|||
); |
|||
|
|||
const budgetResponses = budgets.map((budget) => { |
|||
return this.toBudgetResponse({ |
|||
budget, |
|||
spent: spentByCategoryId.get(budget.categoryId) ?? 0 |
|||
}); |
|||
}); |
|||
|
|||
return { |
|||
budgets: budgetResponses, |
|||
totalBudgeted: budgetResponses.reduce((sum, { amount }) => { |
|||
return sum + amount; |
|||
}, 0), |
|||
totalRemaining: budgetResponses.reduce((sum, { remaining }) => { |
|||
return sum + remaining; |
|||
}, 0), |
|||
totalSpent: budgetResponses.reduce((sum, { spent }) => { |
|||
return sum + spent; |
|||
}, 0) |
|||
}; |
|||
} |
|||
|
|||
public async updateBudget({ |
|||
data, |
|||
id, |
|||
userId |
|||
}: { |
|||
data: UpdateBudgetDto; |
|||
id: string; |
|||
userId: string; |
|||
}): Promise<BudgetResponse> { |
|||
await this.validateBudgetOwnership({ id, userId }); |
|||
await this.validateCategoryOwnership({ |
|||
categoryId: data.categoryId, |
|||
userId |
|||
}); |
|||
|
|||
const month = this.parseBudgetMonth(data.month); |
|||
|
|||
const budget = await this.prismaService.budget.update({ |
|||
data: { |
|||
amount: data.amount, |
|||
category: { connect: { id: data.categoryId } }, |
|||
currency: data.currency, |
|||
month |
|||
}, |
|||
include: { category: true }, |
|||
where: { id } |
|||
}); |
|||
|
|||
return this.toBudgetResponse({ budget, spent: 0 }); |
|||
} |
|||
|
|||
private formatBudgetMonth(month: Date): string { |
|||
return month.toISOString().slice(0, 7); |
|||
} |
|||
|
|||
private getNextMonth(month: Date): Date { |
|||
return new Date( |
|||
Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1) |
|||
); |
|||
} |
|||
|
|||
private async getSpentForBudget({ |
|||
categoryId, |
|||
month, |
|||
userId |
|||
}: { |
|||
categoryId: string; |
|||
month: Date; |
|||
userId: string; |
|||
}) { |
|||
const [expenseSum] = await this.prismaService.expense.groupBy({ |
|||
by: ['categoryId'], |
|||
where: { |
|||
categoryId: { in: [categoryId] }, |
|||
date: { |
|||
gte: month, |
|||
lt: this.getNextMonth(month) |
|||
}, |
|||
userId |
|||
}, |
|||
_sum: { amount: true } |
|||
}); |
|||
|
|||
return expenseSum?._sum.amount ?? 0; |
|||
} |
|||
|
|||
private parseBudgetMonth(month: string): Date { |
|||
return new Date(`${month}-01T00:00:00.000Z`); |
|||
} |
|||
|
|||
private toBudgetResponse({ |
|||
budget, |
|||
spent |
|||
}: { |
|||
budget: { |
|||
amount: number; |
|||
category: { |
|||
color?: string; |
|||
createdAt: Date; |
|||
id: string; |
|||
name: string; |
|||
updatedAt: Date; |
|||
}; |
|||
categoryId: string; |
|||
createdAt: Date; |
|||
currency: string; |
|||
id: string; |
|||
month: Date; |
|||
updatedAt: Date; |
|||
}; |
|||
spent: number; |
|||
}): BudgetResponse { |
|||
return { |
|||
amount: budget.amount, |
|||
category: budget.category, |
|||
categoryId: budget.categoryId, |
|||
createdAt: budget.createdAt, |
|||
currency: budget.currency, |
|||
id: budget.id, |
|||
month: this.formatBudgetMonth(budget.month), |
|||
remaining: budget.amount - spent, |
|||
spent, |
|||
updatedAt: budget.updatedAt |
|||
}; |
|||
} |
|||
|
|||
private async validateBudgetOwnership({ |
|||
id, |
|||
userId |
|||
}: { |
|||
id: string; |
|||
userId: string; |
|||
}) { |
|||
const budget = await this.prismaService.budget.findFirst({ |
|||
where: { id, userId } |
|||
}); |
|||
|
|||
if (!budget) { |
|||
throw new ForbiddenException(); |
|||
} |
|||
} |
|||
|
|||
private async validateCategoryOwnership({ |
|||
categoryId, |
|||
userId |
|||
}: { |
|||
categoryId: string; |
|||
userId: string; |
|||
}) { |
|||
const category = await this.prismaService.expenseCategory.findFirst({ |
|||
where: { |
|||
id: categoryId, |
|||
userId |
|||
} |
|||
}); |
|||
|
|||
if (!category) { |
|||
throw new ForbiddenException(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
@Injectable() |
|||
export class JwtOrApiKeyAuthGuard implements CanActivate { |
|||
private readonly apiKeyGuard = new (AuthGuard('api-key'))(); |
|||
private readonly jwtGuard = new (AuthGuard('jwt'))(); |
|||
|
|||
public async canActivate(context: ExecutionContext): Promise<boolean> { |
|||
try { |
|||
return (await this.jwtGuard.canActivate(context)) as boolean; |
|||
} catch { |
|||
return (await this.apiKeyGuard.canActivate(context)) as boolean; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,557 @@ |
|||
# Budget Feature Implementation Plan |
|||
|
|||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
|||
|
|||
**Goal:** Add monthly category budgets to Ghostfolio with UI summaries and authenticated public integration endpoints. |
|||
|
|||
**Architecture:** Budgets are first-class user-owned records linked to expense categories from the Expenses feature. Budget actuals are calculated from `Expense` rows in the same category and month, so this plan depends on the Expenses feature plan being implemented first. External apps authenticate with `Authorization: Api-Key <api_key>`; the Angular app continues to use JWT bearer auth. |
|||
|
|||
**Tech Stack:** Angular 21, Angular Material, NestJS, Prisma, PostgreSQL, Nx, Jest. |
|||
|
|||
--- |
|||
|
|||
## File Structure |
|||
|
|||
- Modify `prisma/schema.prisma`: add `Budget` and relation to `ExpenseCategory` and `User`. |
|||
- Create DTOs in `libs/common/src/lib/dtos/`: `create-budget.dto.ts`, `update-budget.dto.ts`; export from `index.ts`. |
|||
- Create response interfaces in `libs/common/src/lib/interfaces/responses/`: `budget-response.interface.ts`; export from `libs/common/src/lib/interfaces/index.ts`. |
|||
- Modify `libs/common/src/lib/permissions.ts`: add budget read/write permissions and grant them to `ADMIN` and `USER`. |
|||
- Create API files under `apps/api/src/app/budgets/`: `budgets.module.ts`, `budgets.controller.ts`, `budgets.service.ts`, `budgets.service.spec.ts`. |
|||
- Modify `apps/api/src/app/app.module.ts`: import `BudgetsModule`. |
|||
- Modify `libs/ui/src/lib/services/data.service.ts`: add budget client methods. |
|||
- Modify `libs/common/src/lib/routes/routes.ts`: add `internalRoutes.portfolio.subRoutes.budget`. |
|||
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.component.ts`: add Budget tab. |
|||
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts`: add lazy child route. |
|||
- Create Angular feature files under `apps/client/src/app/pages/portfolio/budget/`: `budget-page.routes.ts`, `budget-page.component.ts`, `budget-page.html`, `budget-page.scss`, `create-or-update-budget-dialog/*`. |
|||
|
|||
## Task 1: Shared Budget Contracts |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `libs/common/src/lib/permissions.ts` |
|||
- Create: `libs/common/src/lib/dtos/create-budget.dto.ts` |
|||
- Create: `libs/common/src/lib/dtos/update-budget.dto.ts` |
|||
- Modify: `libs/common/src/lib/dtos/index.ts` |
|||
- Create: `libs/common/src/lib/interfaces/responses/budget-response.interface.ts` |
|||
- Modify: `libs/common/src/lib/interfaces/index.ts` |
|||
|
|||
- [ ] **Step 1: Add permissions** |
|||
|
|||
Add these permission constants: |
|||
|
|||
```ts |
|||
createBudget: 'createBudget', |
|||
deleteBudget: 'deleteBudget', |
|||
readBudgets: 'readBudgets', |
|||
updateBudget: 'updateBudget', |
|||
``` |
|||
|
|||
Add all four permissions to `ADMIN` and `USER`. Do not add them to `DEMO`. |
|||
|
|||
- [ ] **Step 2: Add DTOs** |
|||
|
|||
Use these DTOs exactly: |
|||
|
|||
```ts |
|||
// create-budget.dto.ts |
|||
export class CreateBudgetDto { |
|||
@IsNumber() |
|||
@Min(0) |
|||
amount: number; |
|||
|
|||
@IsString() |
|||
categoryId: string; |
|||
|
|||
@IsCurrencyCode() |
|||
currency: string; |
|||
|
|||
@Matches(/^\d{4}-\d{2}$/) |
|||
month: string; |
|||
} |
|||
``` |
|||
|
|||
```ts |
|||
// update-budget.dto.ts |
|||
export class UpdateBudgetDto extends CreateBudgetDto { |
|||
@IsString() |
|||
id: string; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Add response interfaces** |
|||
|
|||
```ts |
|||
import { ExpenseCategoryResponse } from './expense-category-response.interface'; |
|||
|
|||
export interface BudgetResponse { |
|||
amount: number; |
|||
category: ExpenseCategoryResponse; |
|||
categoryId: string; |
|||
createdAt: Date; |
|||
currency: string; |
|||
id: string; |
|||
month: string; |
|||
remaining: number; |
|||
spent: number; |
|||
updatedAt: Date; |
|||
} |
|||
|
|||
export interface BudgetsResponse { |
|||
budgets: BudgetResponse[]; |
|||
totalBudgeted: number; |
|||
totalRemaining: number; |
|||
totalSpent: number; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Run common tests** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
npm run test:common |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add libs/common/src/lib |
|||
git commit -m "feat: add budget contracts" |
|||
``` |
|||
|
|||
## Task 2: Prisma Budget Schema |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `prisma/schema.prisma` |
|||
|
|||
- [ ] **Step 1: Add Budget model** |
|||
|
|||
Add: |
|||
|
|||
```prisma |
|||
model Budget { |
|||
amount Float |
|||
category ExpenseCategory @relation(fields: [categoryId], onDelete: Cascade, references: [id]) |
|||
categoryId String |
|||
createdAt DateTime @default(now()) |
|||
currency String |
|||
id String @id @default(uuid()) |
|||
month DateTime |
|||
updatedAt DateTime @updatedAt |
|||
user User @relation(fields: [userId], onDelete: Cascade, references: [id]) |
|||
userId String |
|||
|
|||
@@unique([categoryId, month, userId]) |
|||
@@index([categoryId]) |
|||
@@index([month]) |
|||
@@index([userId]) |
|||
} |
|||
``` |
|||
|
|||
Add relation fields: |
|||
|
|||
```prisma |
|||
model ExpenseCategory { |
|||
budgets Budget[] |
|||
} |
|||
|
|||
model User { |
|||
budgets Budget[] |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Format and validate schema** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
npm run database:format-schema |
|||
npm run database:validate-schema |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 3: Generate Prisma client** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
npm run database:generate-typings |
|||
``` |
|||
|
|||
Expected: PASS and Prisma types include `Budget`. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add prisma/schema.prisma |
|||
git commit -m "feat: add budget prisma model" |
|||
``` |
|||
|
|||
## Task 3: Budget API |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `apps/api/src/app/budgets/budgets.service.ts` |
|||
- Create: `apps/api/src/app/budgets/budgets.service.spec.ts` |
|||
- Create: `apps/api/src/app/budgets/budgets.controller.ts` |
|||
- Create: `apps/api/src/app/budgets/budgets.module.ts` |
|||
- Modify: `apps/api/src/app/app.module.ts` |
|||
|
|||
- [ ] **Step 1: Write failing service tests** |
|||
|
|||
Create tests covering: |
|||
|
|||
```ts |
|||
it('lists budgets for a month and includes spent and remaining amounts'); |
|||
it('creates a budget for a category owned by the current user'); |
|||
it('updates only a budget owned by the current user'); |
|||
it('deletes only a budget owned by the current user'); |
|||
it('rejects duplicate budgets for the same category and month'); |
|||
``` |
|||
|
|||
Mock `PrismaService` methods: |
|||
|
|||
```ts |
|||
budget.findMany; |
|||
budget.create; |
|||
budget.update; |
|||
budget.delete; |
|||
budget.findFirst; |
|||
expense.groupBy; |
|||
expenseCategory.findFirst; |
|||
``` |
|||
|
|||
- [ ] **Step 2: Run failing test** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx test api --testFile=budgets.service.spec.ts |
|||
``` |
|||
|
|||
Expected: FAIL because `BudgetsService` does not exist. |
|||
|
|||
- [ ] **Step 3: Implement month helpers** |
|||
|
|||
Inside `BudgetsService`, implement: |
|||
|
|||
```ts |
|||
private parseBudgetMonth(month: string): Date { |
|||
return new Date(`${month}-01T00:00:00.000Z`); |
|||
} |
|||
|
|||
private formatBudgetMonth(month: Date): string { |
|||
return month.toISOString().slice(0, 7); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Implement service methods** |
|||
|
|||
Implement: |
|||
|
|||
```ts |
|||
getBudgets({ month, userId }: { month: string; userId: string }) |
|||
getBudget({ id, userId }: { id: string; userId: string }) |
|||
createBudget({ data, userId }: { data: CreateBudgetDto; userId: string }) |
|||
updateBudget({ data, id, userId }: { data: UpdateBudgetDto; id: string; userId: string }) |
|||
deleteBudget({ id, userId }: { id: string; userId: string }) |
|||
``` |
|||
|
|||
Rules: |
|||
|
|||
- Always scope by `userId`. |
|||
- Verify the category belongs to the user before create/update. |
|||
- Store `month` as the first day of that month in UTC. |
|||
- Calculate `spent` from expenses where `date >= monthStart`, `date < nextMonthStart`, `categoryId` matches, and `userId` matches. |
|||
- Calculate `remaining = amount - spent`. |
|||
- Return summary totals in `BudgetsResponse`. |
|||
|
|||
- [ ] **Step 5: Implement controller** |
|||
|
|||
Add routes: |
|||
|
|||
```http |
|||
GET /api/v1/budgets?month=2026-06 |
|||
POST /api/v1/budgets |
|||
GET /api/v1/budgets/:id |
|||
PUT /api/v1/budgets/:id |
|||
DELETE /api/v1/budgets/:id |
|||
``` |
|||
|
|||
Use: |
|||
|
|||
```ts |
|||
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) |
|||
``` |
|||
|
|||
Apply permissions: |
|||
|
|||
```ts |
|||
readBudgets; |
|||
createBudget; |
|||
updateBudget; |
|||
deleteBudget; |
|||
``` |
|||
|
|||
External integration request example: |
|||
|
|||
```bash |
|||
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \ |
|||
"https://example.com/api/v1/budgets?month=2026-06" |
|||
``` |
|||
|
|||
- [ ] **Step 6: Register module** |
|||
|
|||
Import `BudgetsModule` in `apps/api/src/app/app.module.ts`. |
|||
|
|||
- [ ] **Step 7: Run API tests** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx test api --testFile=budgets.service.spec.ts |
|||
npm run test:api |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 8: Commit** |
|||
|
|||
```bash |
|||
git add apps/api/src/app/budgets apps/api/src/app/app.module.ts |
|||
git commit -m "feat: add budgets api" |
|||
``` |
|||
|
|||
## Task 4: Angular Data Service |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `libs/ui/src/lib/services/data.service.ts` |
|||
|
|||
- [ ] **Step 1: Add imports** |
|||
|
|||
Import `CreateBudgetDto`, `UpdateBudgetDto`, `BudgetResponse`, and `BudgetsResponse`. |
|||
|
|||
- [ ] **Step 2: Add data methods** |
|||
|
|||
```ts |
|||
public fetchBudgets({ month }: { month: string }) { |
|||
let params = new HttpParams(); |
|||
params = params.append('month', month); |
|||
|
|||
return this.http.get<BudgetsResponse>('/api/v1/budgets', { params }); |
|||
} |
|||
|
|||
public postBudget(aBudget: CreateBudgetDto) { |
|||
return this.http.post<BudgetResponse>('/api/v1/budgets', aBudget); |
|||
} |
|||
|
|||
public putBudget(aBudget: UpdateBudgetDto) { |
|||
return this.http.put<BudgetResponse>(`/api/v1/budgets/${aBudget.id}`, aBudget); |
|||
} |
|||
|
|||
public deleteBudget(aId: string) { |
|||
return this.http.delete<BudgetResponse>(`/api/v1/budgets/${aId}`); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Run UI library lint** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx run ui:lint |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add libs/ui/src/lib/services/data.service.ts |
|||
git commit -m "feat: add budget data service methods" |
|||
``` |
|||
|
|||
## Task 5: Budget Portfolio Tab UI |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `libs/common/src/lib/routes/routes.ts` |
|||
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.component.ts` |
|||
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.routes.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.component.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.html` |
|||
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.scss` |
|||
|
|||
- [ ] **Step 1: Add route metadata** |
|||
|
|||
Add: |
|||
|
|||
```ts |
|||
budget: { |
|||
path: 'budget', |
|||
routerLink: ['/portfolio', 'budget'], |
|||
title: $localize`Budget` |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Add tab** |
|||
|
|||
In `PortfolioPageComponent`, import an icon such as `walletOutline` from `ionicons/icons`, register it with `addIcons`, and add: |
|||
|
|||
```ts |
|||
{ |
|||
iconName: 'wallet-outline', |
|||
label: internalRoutes.portfolio.subRoutes.budget.title, |
|||
routerLink: internalRoutes.portfolio.subRoutes.budget.routerLink |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Add lazy route** |
|||
|
|||
Add child route: |
|||
|
|||
```ts |
|||
{ |
|||
path: internalRoutes.portfolio.subRoutes.budget.path, |
|||
loadChildren: () => |
|||
import('./budget/budget-page.routes').then((m) => m.routes) |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Implement page** |
|||
|
|||
The page must: |
|||
|
|||
- default to the current month formatted as `YYYY-MM` |
|||
- fetch categories through `fetchExpenseCategories()` |
|||
- fetch budgets through `fetchBudgets({ month })` |
|||
- show total budgeted, total spent, and total remaining |
|||
- show a Material table with category, budget amount, spent, remaining, and actions |
|||
- open a create/edit dialog for budgets |
|||
|
|||
- [ ] **Step 5: Run client checks** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx run client:lint |
|||
nx run client:build |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 6: Commit** |
|||
|
|||
```bash |
|||
git add libs/common/src/lib/routes/routes.ts apps/client/src/app/pages/portfolio |
|||
git commit -m "feat: add budget portfolio tab" |
|||
``` |
|||
|
|||
## Task 6: Budget Dialog |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html` |
|||
- Create: `apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss` |
|||
|
|||
- [ ] **Step 1: Implement dialog form** |
|||
|
|||
Use Reactive Forms with fields: |
|||
|
|||
```ts |
|||
(amount, categoryId, currency, month); |
|||
``` |
|||
|
|||
Required fields: |
|||
|
|||
```ts |
|||
(amount, categoryId, currency, month); |
|||
``` |
|||
|
|||
- [ ] **Step 2: Validate unique category per month in UI** |
|||
|
|||
When creating a budget, hide or disable categories that already have a budget for the selected month. |
|||
|
|||
- [ ] **Step 3: Wire dialog to page** |
|||
|
|||
After create/update/delete, refresh budgets and call `markForCheck()`. |
|||
|
|||
- [ ] **Step 4: Run client checks** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx run client:lint |
|||
nx run client:build |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add apps/client/src/app/pages/portfolio/budget |
|||
git commit -m "feat: add budget management dialog" |
|||
``` |
|||
|
|||
## Task 7: Final Verification |
|||
|
|||
- [ ] **Step 1: Run schema checks** |
|||
|
|||
```bash |
|||
npm run database:validate-schema |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 2: Run focused tests** |
|||
|
|||
```bash |
|||
npm run test:common |
|||
npm run test:api |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 3: Run production build** |
|||
|
|||
```bash |
|||
npm run build:production |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 4: Manual API verification** |
|||
|
|||
Create an API key from the existing API page, then run: |
|||
|
|||
```bash |
|||
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \ |
|||
"http://localhost:3333/api/v1/budgets?month=2026-06" |
|||
``` |
|||
|
|||
Expected: `200 OK` and a `BudgetsResponse`. |
|||
|
|||
- [ ] **Step 5: Manual UI verification** |
|||
|
|||
Start the app: |
|||
|
|||
```bash |
|||
npm run start:server |
|||
npm run start:client |
|||
``` |
|||
|
|||
Open `https://localhost:4200/en/portfolio/budget`. Confirm the Budget tab appears, budgets can be created for expense categories, totals update after expenses are added, and duplicate category/month budgets are prevented. |
|||
|
|||
## Self-Review |
|||
|
|||
- Spec coverage: first-class budget records, UI tab, CRUD API, API-key integration, JWT UI access, user isolation, month summaries, and dependency on expenses are covered. |
|||
- Placeholder scan: no placeholder markers or unassigned implementation decisions remain. |
|||
- Type consistency: DTO names, route names, permission names, response names, and dependency on `ExpenseCategory` match across tasks. |
|||
@ -0,0 +1,792 @@ |
|||
# Expenses Feature Implementation Plan |
|||
|
|||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
|||
|
|||
**Goal:** Add first-class expense tracking to Ghostfolio with Angular UI and authenticated public integration endpoints. |
|||
|
|||
**Architecture:** Expenses are separate from investment `Order` activities. The feature introduces user-owned `ExpenseCategory` and `Expense` Prisma models, shared DTOs/interfaces in `libs/common`, NestJS API modules under `/api/v1`, and a Portfolio tab using the existing `gf-page-tabs` pattern. External apps authenticate with `Authorization: Api-Key <api_key>`; the Angular app continues to use JWT bearer auth. |
|||
|
|||
**Tech Stack:** Angular 21, Angular Material, NestJS, Prisma, PostgreSQL, Nx, Jest. |
|||
|
|||
--- |
|||
|
|||
## File Structure |
|||
|
|||
- Modify `prisma/schema.prisma`: add `ExpenseCategory`, `Expense`, user/account/tag relations. |
|||
- Modify `libs/common/src/lib/permissions.ts`: add expense permissions and grant them to `ADMIN` and `USER`. |
|||
- Create DTOs in `libs/common/src/lib/dtos/`: `create-expense-category.dto.ts`, `update-expense-category.dto.ts`, `create-expense.dto.ts`, `update-expense.dto.ts`; export from `index.ts`. |
|||
- Create response interfaces in `libs/common/src/lib/interfaces/responses/`: `expense-category-response.interface.ts`, `expenses-response.interface.ts`; export from `libs/common/src/lib/interfaces/index.ts`. |
|||
- Create API module files under `apps/api/src/app/expenses/`: `expenses.module.ts`, `expenses.controller.ts`, `expenses.service.ts`, `expenses.service.spec.ts`, `expense-categories.controller.ts`, `expense-categories.service.ts`, `expense-categories.service.spec.ts`. |
|||
- Create `apps/api/src/guards/jwt-or-api-key-auth.guard.ts` to support JWT and API-key integrations on the new endpoints. |
|||
- Modify `apps/api/src/app/app.module.ts`: import `ExpensesModule`. |
|||
- Modify `libs/ui/src/lib/services/data.service.ts`: add expense category and expense client methods. |
|||
- Modify `libs/common/src/lib/routes/routes.ts`: add `internalRoutes.portfolio.subRoutes.expenses`. |
|||
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.component.ts`: add Expenses tab. |
|||
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts`: add lazy child route. |
|||
- Create Angular feature files under `apps/client/src/app/pages/portfolio/expenses/`: `expenses-page.routes.ts`, `expenses-page.component.ts`, `expenses-page.html`, `expenses-page.scss`, `create-or-update-expense-dialog/*`, `create-or-update-expense-category-dialog/*`. |
|||
|
|||
## Task 1: Shared Expense Contracts |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `libs/common/src/lib/permissions.ts` |
|||
- Create: `libs/common/src/lib/dtos/create-expense-category.dto.ts` |
|||
- Create: `libs/common/src/lib/dtos/update-expense-category.dto.ts` |
|||
- Create: `libs/common/src/lib/dtos/create-expense.dto.ts` |
|||
- Create: `libs/common/src/lib/dtos/update-expense.dto.ts` |
|||
- Modify: `libs/common/src/lib/dtos/index.ts` |
|||
- Create: `libs/common/src/lib/interfaces/responses/expense-category-response.interface.ts` |
|||
- Create: `libs/common/src/lib/interfaces/responses/expenses-response.interface.ts` |
|||
- Modify: `libs/common/src/lib/interfaces/index.ts` |
|||
|
|||
- [ ] **Step 1: Add permissions** |
|||
|
|||
Add these permission constants to `permissions`: |
|||
|
|||
```ts |
|||
createExpense: 'createExpense', |
|||
createExpenseCategory: 'createExpenseCategory', |
|||
deleteExpense: 'deleteExpense', |
|||
deleteExpenseCategory: 'deleteExpenseCategory', |
|||
readExpenses: 'readExpenses', |
|||
readExpenseCategories: 'readExpenseCategories', |
|||
updateExpense: 'updateExpense', |
|||
updateExpenseCategory: 'updateExpenseCategory', |
|||
``` |
|||
|
|||
Add all eight permissions to `ADMIN` and `USER`. Do not add them to `DEMO`. |
|||
|
|||
- [ ] **Step 2: Add DTO files** |
|||
|
|||
Use these DTO shapes exactly: |
|||
|
|||
```ts |
|||
// create-expense-category.dto.ts |
|||
export class CreateExpenseCategoryDto { |
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsHexColor() |
|||
@IsOptional() |
|||
color?: string; |
|||
} |
|||
``` |
|||
|
|||
```ts |
|||
// update-expense-category.dto.ts |
|||
export class UpdateExpenseCategoryDto { |
|||
@IsString() |
|||
id: string; |
|||
|
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsHexColor() |
|||
@IsOptional() |
|||
color?: string; |
|||
} |
|||
``` |
|||
|
|||
```ts |
|||
// create-expense.dto.ts |
|||
export class CreateExpenseDto { |
|||
@IsString() |
|||
@IsOptional() |
|||
accountId?: string; |
|||
|
|||
@IsNumber() |
|||
@Min(0) |
|||
amount: number; |
|||
|
|||
@IsString() |
|||
@IsOptional() |
|||
categoryId?: string; |
|||
|
|||
@IsString() |
|||
@IsOptional() |
|||
comment?: string; |
|||
|
|||
@IsCurrencyCode() |
|||
currency: string; |
|||
|
|||
@IsISO8601() |
|||
date: string; |
|||
|
|||
@IsString() |
|||
@IsOptional() |
|||
merchant?: string; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
tagIds?: string[]; |
|||
} |
|||
``` |
|||
|
|||
```ts |
|||
// update-expense.dto.ts |
|||
export class UpdateExpenseDto extends CreateExpenseDto { |
|||
@IsString() |
|||
id: string; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Add response interfaces** |
|||
|
|||
Use these response contracts: |
|||
|
|||
```ts |
|||
export interface ExpenseCategoryResponse { |
|||
color?: string; |
|||
createdAt: Date; |
|||
id: string; |
|||
name: string; |
|||
updatedAt: Date; |
|||
} |
|||
``` |
|||
|
|||
```ts |
|||
import { Account, Tag } from '@prisma/client'; |
|||
|
|||
export interface ExpenseResponse { |
|||
account?: Account; |
|||
accountId?: string; |
|||
amount: number; |
|||
categoryId?: string; |
|||
comment?: string; |
|||
createdAt: Date; |
|||
currency: string; |
|||
date: Date; |
|||
id: string; |
|||
merchant?: string; |
|||
tags: Tag[]; |
|||
updatedAt: Date; |
|||
} |
|||
|
|||
export interface ExpensesResponse { |
|||
count: number; |
|||
expenses: ExpenseResponse[]; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Run common tests** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
npm run test:common |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add libs/common/src/lib |
|||
git commit -m "feat: add expense contracts" |
|||
``` |
|||
|
|||
## Task 2: Prisma Expense Schema |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `prisma/schema.prisma` |
|||
|
|||
- [ ] **Step 1: Add Prisma models** |
|||
|
|||
Add these models and relations: |
|||
|
|||
```prisma |
|||
model ExpenseCategory { |
|||
color String? |
|||
createdAt DateTime @default(now()) |
|||
expenses Expense[] |
|||
id String @id @default(uuid()) |
|||
name String |
|||
updatedAt DateTime @updatedAt |
|||
user User @relation(fields: [userId], onDelete: Cascade, references: [id]) |
|||
userId String |
|||
|
|||
@@unique([name, userId]) |
|||
@@index([name]) |
|||
@@index([userId]) |
|||
} |
|||
|
|||
model Expense { |
|||
account Account? @relation(fields: [accountId, accountUserId], references: [id, userId]) |
|||
accountId String? |
|||
accountUserId String? |
|||
amount Float |
|||
category ExpenseCategory? @relation(fields: [categoryId], onDelete: SetNull, references: [id]) |
|||
categoryId String? |
|||
comment String? |
|||
createdAt DateTime @default(now()) |
|||
currency String |
|||
date DateTime |
|||
id String @id @default(uuid()) |
|||
merchant String? |
|||
tags Tag[] |
|||
updatedAt DateTime @updatedAt |
|||
user User @relation(fields: [userId], onDelete: Cascade, references: [id]) |
|||
userId String |
|||
|
|||
@@index([accountId]) |
|||
@@index([categoryId]) |
|||
@@index([currency]) |
|||
@@index([date]) |
|||
@@index([merchant]) |
|||
@@index([userId]) |
|||
} |
|||
``` |
|||
|
|||
Add relation fields: |
|||
|
|||
```prisma |
|||
model Account { |
|||
expenses Expense[] |
|||
} |
|||
|
|||
model Tag { |
|||
expenses Expense[] |
|||
} |
|||
|
|||
model User { |
|||
expenseCategories ExpenseCategory[] |
|||
expenses Expense[] |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Format and validate schema** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
npm run database:format-schema |
|||
npm run database:validate-schema |
|||
``` |
|||
|
|||
Expected: both commands PASS. |
|||
|
|||
- [ ] **Step 3: Generate Prisma client** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
npm run database:generate-typings |
|||
``` |
|||
|
|||
Expected: PASS and Prisma types include `Expense` and `ExpenseCategory`. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add prisma/schema.prisma |
|||
git commit -m "feat: add expense prisma models" |
|||
``` |
|||
|
|||
## Task 3: JWT Or API-Key Guard |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `apps/api/src/guards/jwt-or-api-key-auth.guard.ts` |
|||
|
|||
- [ ] **Step 1: Create the guard** |
|||
|
|||
```ts |
|||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
@Injectable() |
|||
export class JwtOrApiKeyAuthGuard implements CanActivate { |
|||
private readonly jwtGuard = new (AuthGuard('jwt'))(); |
|||
private readonly apiKeyGuard = new (AuthGuard('api-key'))(); |
|||
|
|||
public async canActivate(context: ExecutionContext): Promise<boolean> { |
|||
try { |
|||
return (await this.jwtGuard.canActivate(context)) as boolean; |
|||
} catch { |
|||
return (await this.apiKeyGuard.canActivate(context)) as boolean; |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Build API** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx run api:build |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add apps/api/src/guards/jwt-or-api-key-auth.guard.ts |
|||
git commit -m "feat: support jwt or api key auth" |
|||
``` |
|||
|
|||
## Task 4: Expense Category API |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `apps/api/src/app/expenses/expense-categories.service.ts` |
|||
- Create: `apps/api/src/app/expenses/expense-categories.service.spec.ts` |
|||
- Create: `apps/api/src/app/expenses/expense-categories.controller.ts` |
|||
|
|||
- [ ] **Step 1: Write failing service tests** |
|||
|
|||
Create tests covering: |
|||
|
|||
```ts |
|||
it('returns only categories for the current user'); |
|||
it('creates a category for the current user'); |
|||
it('updates only a category owned by the current user'); |
|||
it('blocks deletion when expenses reference the category'); |
|||
it('deletes an unused category owned by the current user'); |
|||
``` |
|||
|
|||
Mock `PrismaService` with `expenseCategory.findMany`, `expenseCategory.create`, `expenseCategory.update`, `expenseCategory.delete`, and `expense.count`. |
|||
|
|||
- [ ] **Step 2: Run failing test** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx test api --testFile=expense-categories.service.spec.ts |
|||
``` |
|||
|
|||
Expected: FAIL because the service does not exist yet. |
|||
|
|||
- [ ] **Step 3: Implement service** |
|||
|
|||
Implement methods: |
|||
|
|||
```ts |
|||
getCategories({ userId }: { userId: string }) |
|||
createCategory({ data, userId }: { data: CreateExpenseCategoryDto; userId: string }) |
|||
updateCategory({ data, id, userId }: { data: UpdateExpenseCategoryDto; id: string; userId: string }) |
|||
deleteCategory({ id, userId }: { id: string; userId: string }) |
|||
``` |
|||
|
|||
Deletion must check `expense.count({ where: { categoryId: id, userId } })`; if count is greater than `0`, throw `HttpException(CONFLICT, 409)`. |
|||
|
|||
- [ ] **Step 4: Implement controller** |
|||
|
|||
Add routes: |
|||
|
|||
```http |
|||
GET /api/v1/expense-categories |
|||
POST /api/v1/expense-categories |
|||
PUT /api/v1/expense-categories/:id |
|||
DELETE /api/v1/expense-categories/:id |
|||
``` |
|||
|
|||
Use: |
|||
|
|||
```ts |
|||
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) |
|||
``` |
|||
|
|||
Apply permissions: |
|||
|
|||
```ts |
|||
readExpenseCategories; |
|||
createExpenseCategory; |
|||
updateExpenseCategory; |
|||
deleteExpenseCategory; |
|||
``` |
|||
|
|||
- [ ] **Step 5: Run service tests** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx test api --testFile=expense-categories.service.spec.ts |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 6: Commit** |
|||
|
|||
```bash |
|||
git add apps/api/src/app/expenses apps/api/src/guards |
|||
git commit -m "feat: add expense category api" |
|||
``` |
|||
|
|||
## Task 5: Expense API |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `apps/api/src/app/expenses/expenses.service.ts` |
|||
- Create: `apps/api/src/app/expenses/expenses.service.spec.ts` |
|||
- Create: `apps/api/src/app/expenses/expenses.controller.ts` |
|||
- Create: `apps/api/src/app/expenses/expenses.module.ts` |
|||
- Modify: `apps/api/src/app/app.module.ts` |
|||
|
|||
- [ ] **Step 1: Write failing service tests** |
|||
|
|||
Create tests covering: |
|||
|
|||
```ts |
|||
it('lists paginated expenses for the current user'); |
|||
it('filters expenses by date range and category'); |
|||
it('creates an expense with optional account, category, and tags'); |
|||
it('updates only an expense owned by the current user'); |
|||
it('deletes only an expense owned by the current user'); |
|||
``` |
|||
|
|||
- [ ] **Step 2: Run failing test** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx test api --testFile=expenses.service.spec.ts |
|||
``` |
|||
|
|||
Expected: FAIL because `ExpensesService` does not exist. |
|||
|
|||
- [ ] **Step 3: Implement service** |
|||
|
|||
Implement methods: |
|||
|
|||
```ts |
|||
getExpenses({ |
|||
categoryId, |
|||
from, |
|||
skip, |
|||
sortColumn = 'date', |
|||
sortDirection = 'desc', |
|||
take, |
|||
to, |
|||
userId |
|||
}); |
|||
getExpense({ id, userId }); |
|||
createExpense({ data, userId }); |
|||
updateExpense({ data, id, userId }); |
|||
deleteExpense({ id, userId }); |
|||
``` |
|||
|
|||
Always scope Prisma `where` clauses by `userId`. Include `account`, `category`, and `tags` in returned expenses. |
|||
|
|||
- [ ] **Step 4: Implement controller** |
|||
|
|||
Add routes: |
|||
|
|||
```http |
|||
GET /api/v1/expenses?from=2026-06-01&to=2026-06-30&categoryId=...&skip=0&take=50 |
|||
POST /api/v1/expenses |
|||
GET /api/v1/expenses/:id |
|||
PUT /api/v1/expenses/:id |
|||
DELETE /api/v1/expenses/:id |
|||
``` |
|||
|
|||
Use `JwtOrApiKeyAuthGuard` and `HasPermissionGuard`. |
|||
|
|||
Apply permissions: |
|||
|
|||
```ts |
|||
readExpenses; |
|||
createExpense; |
|||
updateExpense; |
|||
deleteExpense; |
|||
``` |
|||
|
|||
External integration request example: |
|||
|
|||
```bash |
|||
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \ |
|||
"https://example.com/api/v1/expenses?from=2026-06-01&to=2026-06-30" |
|||
``` |
|||
|
|||
- [ ] **Step 5: Register module** |
|||
|
|||
Import `ExpensesModule` in `apps/api/src/app/app.module.ts`. |
|||
|
|||
- [ ] **Step 6: Run tests** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx test api --testFile=expenses.service.spec.ts |
|||
npm run test:api |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 7: Commit** |
|||
|
|||
```bash |
|||
git add apps/api/src/app/expenses apps/api/src/app/app.module.ts |
|||
git commit -m "feat: add expenses api" |
|||
``` |
|||
|
|||
## Task 6: Angular Data Service |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `libs/ui/src/lib/services/data.service.ts` |
|||
|
|||
- [ ] **Step 1: Add imports** |
|||
|
|||
Import the new DTOs and response interfaces from `@ghostfolio/common`. |
|||
|
|||
- [ ] **Step 2: Add category methods** |
|||
|
|||
```ts |
|||
public fetchExpenseCategories() { |
|||
return this.http.get<ExpenseCategoryResponse[]>('/api/v1/expense-categories'); |
|||
} |
|||
|
|||
public postExpenseCategory(aCategory: CreateExpenseCategoryDto) { |
|||
return this.http.post<ExpenseCategoryResponse>('/api/v1/expense-categories', aCategory); |
|||
} |
|||
|
|||
public putExpenseCategory(aCategory: UpdateExpenseCategoryDto) { |
|||
return this.http.put<ExpenseCategoryResponse>( |
|||
`/api/v1/expense-categories/${aCategory.id}`, |
|||
aCategory |
|||
); |
|||
} |
|||
|
|||
public deleteExpenseCategory(aId: string) { |
|||
return this.http.delete<ExpenseCategoryResponse>(`/api/v1/expense-categories/${aId}`); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Add expense methods** |
|||
|
|||
```ts |
|||
public fetchExpenses({ categoryId, from, skip, sortColumn, sortDirection, take, to }) { |
|||
let params = new HttpParams(); |
|||
if (categoryId) params = params.append('categoryId', categoryId); |
|||
if (from) params = params.append('from', format(from, DATE_FORMAT, { in: utc })); |
|||
if (to) params = params.append('to', format(to, DATE_FORMAT, { in: utc })); |
|||
if (skip) params = params.append('skip', skip); |
|||
if (sortColumn) params = params.append('sortColumn', sortColumn); |
|||
if (sortDirection) params = params.append('sortDirection', sortDirection); |
|||
if (take) params = params.append('take', take); |
|||
return this.http.get<ExpensesResponse>('/api/v1/expenses', { params }); |
|||
} |
|||
|
|||
public postExpense(aExpense: CreateExpenseDto) { |
|||
return this.http.post<ExpenseResponse>('/api/v1/expenses', aExpense); |
|||
} |
|||
|
|||
public putExpense(aExpense: UpdateExpenseDto) { |
|||
return this.http.put<ExpenseResponse>(`/api/v1/expenses/${aExpense.id}`, aExpense); |
|||
} |
|||
|
|||
public deleteExpense(aId: string) { |
|||
return this.http.delete<ExpenseResponse>(`/api/v1/expenses/${aId}`); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Build UI library** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx run ui:lint |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add libs/ui/src/lib/services/data.service.ts |
|||
git commit -m "feat: add expense data service methods" |
|||
``` |
|||
|
|||
## Task 7: Expenses Portfolio Tab UI |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `libs/common/src/lib/routes/routes.ts` |
|||
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.component.ts` |
|||
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.routes.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.component.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.html` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.scss` |
|||
|
|||
- [ ] **Step 1: Add route metadata** |
|||
|
|||
Add: |
|||
|
|||
```ts |
|||
expenses: { |
|||
path: 'expenses', |
|||
routerLink: ['/portfolio', 'expenses'], |
|||
title: $localize`Expenses` |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Add tab** |
|||
|
|||
In `PortfolioPageComponent`, import an icon such as `receiptOutline` from `ionicons/icons`, register it with `addIcons`, and add: |
|||
|
|||
```ts |
|||
{ |
|||
iconName: 'receipt-outline', |
|||
label: internalRoutes.portfolio.subRoutes.expenses.title, |
|||
routerLink: internalRoutes.portfolio.subRoutes.expenses.routerLink |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Add lazy route** |
|||
|
|||
Add child route: |
|||
|
|||
```ts |
|||
{ |
|||
path: internalRoutes.portfolio.subRoutes.expenses.path, |
|||
loadChildren: () => |
|||
import('./expenses/expenses-page.routes').then((m) => m.routes) |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Implement page** |
|||
|
|||
The page must: |
|||
|
|||
- fetch categories and expenses on init |
|||
- show a Material table with date, merchant, category, amount, currency, account, comment, actions |
|||
- support pagination and date/category filters |
|||
- open create/edit/delete flows through dialogs |
|||
|
|||
- [ ] **Step 5: Run client lint/build** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx run client:lint |
|||
nx run client:build |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 6: Commit** |
|||
|
|||
```bash |
|||
git add libs/common/src/lib/routes/routes.ts apps/client/src/app/pages/portfolio |
|||
git commit -m "feat: add expenses portfolio tab" |
|||
``` |
|||
|
|||
## Task 8: Expense Dialogs |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-dialog/create-or-update-expense-dialog.component.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-dialog/create-or-update-expense-dialog.html` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-dialog/create-or-update-expense-dialog.scss` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-category-dialog/create-or-update-expense-category-dialog.component.ts` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-category-dialog/create-or-update-expense-category-dialog.html` |
|||
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-category-dialog/create-or-update-expense-category-dialog.scss` |
|||
|
|||
- [ ] **Step 1: Implement expense dialog fields** |
|||
|
|||
Use Reactive Forms with fields: |
|||
|
|||
```ts |
|||
(accountId, amount, categoryId, comment, currency, date, merchant, tagIds); |
|||
``` |
|||
|
|||
Required fields: |
|||
|
|||
```ts |
|||
(amount, currency, date); |
|||
``` |
|||
|
|||
- [ ] **Step 2: Implement category dialog fields** |
|||
|
|||
Use Reactive Forms with fields: |
|||
|
|||
```ts |
|||
(name, color); |
|||
``` |
|||
|
|||
Required field: |
|||
|
|||
```ts |
|||
name; |
|||
``` |
|||
|
|||
- [ ] **Step 3: Wire dialogs to page** |
|||
|
|||
After each create/update/delete operation, refresh category and expense data and call `markForCheck()`. |
|||
|
|||
- [ ] **Step 4: Run client checks** |
|||
|
|||
Run: |
|||
|
|||
```bash |
|||
nx run client:lint |
|||
nx run client:build |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add apps/client/src/app/pages/portfolio/expenses |
|||
git commit -m "feat: add expense management dialogs" |
|||
``` |
|||
|
|||
## Task 9: Final Verification |
|||
|
|||
- [ ] **Step 1: Run schema checks** |
|||
|
|||
```bash |
|||
npm run database:validate-schema |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 2: Run focused tests** |
|||
|
|||
```bash |
|||
npm run test:common |
|||
npm run test:api |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 3: Run production build** |
|||
|
|||
```bash |
|||
npm run build:production |
|||
``` |
|||
|
|||
Expected: PASS. |
|||
|
|||
- [ ] **Step 4: Manual API verification** |
|||
|
|||
Create an API key from the existing API page, then run: |
|||
|
|||
```bash |
|||
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \ |
|||
http://localhost:3333/api/v1/expense-categories |
|||
``` |
|||
|
|||
Expected: `200 OK` and an array response. |
|||
|
|||
- [ ] **Step 5: Manual UI verification** |
|||
|
|||
Start the app: |
|||
|
|||
```bash |
|||
npm run start:server |
|||
npm run start:client |
|||
``` |
|||
|
|||
Open `https://localhost:4200/en/portfolio/expenses`. Confirm the Expenses tab appears, categories can be created, expenses can be created, and the table refreshes. |
|||
|
|||
## Self-Review |
|||
|
|||
- Spec coverage: first-class expense records, UI tab, CRUD API, API-key integration, JWT UI access, user isolation, categories, tags, and accounts are covered. |
|||
- Placeholder scan: no placeholder markers or unassigned implementation decisions remain. |
|||
- Type consistency: DTO names, route names, permission names, and response names match across tasks. |
|||
@ -0,0 +1,18 @@ |
|||
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; |
|||
|
|||
import { IsNumber, IsString, Matches, Min } from 'class-validator'; |
|||
|
|||
export class CreateBudgetDto { |
|||
@IsNumber() |
|||
@Min(0) |
|||
amount: number; |
|||
|
|||
@IsString() |
|||
categoryId: string; |
|||
|
|||
@IsCurrencyCode() |
|||
currency: string; |
|||
|
|||
@Matches(/^\d{4}-\d{2}$/) |
|||
month: string; |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import { IsHexColor, IsOptional, IsString } from 'class-validator'; |
|||
|
|||
export class CreateExpenseCategoryDto { |
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsHexColor() |
|||
@IsOptional() |
|||
color?: string; |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; |
|||
|
|||
import { |
|||
IsArray, |
|||
IsISO8601, |
|||
IsNumber, |
|||
IsOptional, |
|||
IsString, |
|||
Min |
|||
} from 'class-validator'; |
|||
|
|||
export class CreateExpenseDto { |
|||
@IsString() |
|||
@IsOptional() |
|||
accountId?: string; |
|||
|
|||
@IsNumber() |
|||
@Min(0) |
|||
amount: number; |
|||
|
|||
@IsString() |
|||
@IsOptional() |
|||
categoryId?: string; |
|||
|
|||
@IsString() |
|||
@IsOptional() |
|||
comment?: string; |
|||
|
|||
@IsCurrencyCode() |
|||
currency: string; |
|||
|
|||
@IsISO8601() |
|||
date: string; |
|||
|
|||
@IsString() |
|||
@IsOptional() |
|||
merchant?: string; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
tagIds?: string[]; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
import { CreateBudgetDto } from './create-budget.dto'; |
|||
|
|||
export class UpdateBudgetDto extends CreateBudgetDto { |
|||
@IsString() |
|||
id: string; |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
import { IsHexColor, IsOptional, IsString } from 'class-validator'; |
|||
|
|||
export class UpdateExpenseCategoryDto { |
|||
@IsString() |
|||
id: string; |
|||
|
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsHexColor() |
|||
@IsOptional() |
|||
color?: string; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
import { CreateExpenseDto } from './create-expense.dto'; |
|||
|
|||
export class UpdateExpenseDto extends CreateExpenseDto { |
|||
@IsString() |
|||
id: string; |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
import { ExpenseCategoryResponse } from './expense-category-response.interface'; |
|||
|
|||
export interface BudgetResponse { |
|||
amount: number; |
|||
category: ExpenseCategoryResponse; |
|||
categoryId: string; |
|||
createdAt: Date; |
|||
currency: string; |
|||
id: string; |
|||
month: string; |
|||
remaining: number; |
|||
spent: number; |
|||
updatedAt: Date; |
|||
} |
|||
|
|||
export interface BudgetsResponse { |
|||
budgets: BudgetResponse[]; |
|||
totalBudgeted: number; |
|||
totalRemaining: number; |
|||
totalSpent: number; |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
export interface ExpenseCategoryResponse { |
|||
color?: string; |
|||
createdAt: Date; |
|||
id: string; |
|||
name: string; |
|||
updatedAt: Date; |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
import { Account, Tag } from '@prisma/client'; |
|||
|
|||
export interface ExpenseResponse { |
|||
account?: Account; |
|||
accountId?: string; |
|||
amount: number; |
|||
categoryId?: string; |
|||
comment?: string; |
|||
createdAt: Date; |
|||
currency: string; |
|||
date: Date; |
|||
id: string; |
|||
merchant?: string; |
|||
tags: Tag[]; |
|||
updatedAt: Date; |
|||
} |
|||
|
|||
export interface ExpensesResponse { |
|||
count: number; |
|||
expenses: ExpenseResponse[]; |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
import { getPermissions, permissions } from './permissions'; |
|||
|
|||
describe('permissions', () => { |
|||
it('grants budget and expense permissions to users and admins only', () => { |
|||
const featurePermissions = [ |
|||
permissions.createBudget, |
|||
permissions.createExpense, |
|||
permissions.createExpenseCategory, |
|||
permissions.deleteBudget, |
|||
permissions.deleteExpense, |
|||
permissions.deleteExpenseCategory, |
|||
permissions.readBudgets, |
|||
permissions.readExpenses, |
|||
permissions.readExpenseCategories, |
|||
permissions.updateBudget, |
|||
permissions.updateExpense, |
|||
permissions.updateExpenseCategory |
|||
]; |
|||
|
|||
expect(getPermissions('ADMIN')).toEqual( |
|||
expect.arrayContaining(featurePermissions) |
|||
); |
|||
expect(getPermissions('USER')).toEqual( |
|||
expect.arrayContaining(featurePermissions) |
|||
); |
|||
|
|||
for (const permission of featurePermissions) { |
|||
expect(getPermissions('DEMO')).not.toContain(permission); |
|||
} |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue