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