From d198faab12a8f008b2b0cf4b3b680be1969d5d8a Mon Sep 17 00:00:00 2001 From: Yip Shing him Date: Tue, 23 Jun 2026 23:33:08 +0100 Subject: [PATCH] feat: add budget category management api --- .../api/src/app/budgets/budgets.controller.ts | 43 +++++- .../src/app/budgets/budgets.service.spec.ts | 136 +++++++++++++++++- apps/api/src/app/budgets/budgets.service.ts | 110 +++++++++++++- libs/ui/src/lib/services/data.service.spec.ts | 52 ++++++- libs/ui/src/lib/services/data.service.ts | 26 ++++ 5 files changed, 360 insertions(+), 7 deletions(-) diff --git a/apps/api/src/app/budgets/budgets.controller.ts b/apps/api/src/app/budgets/budgets.controller.ts index d9f64fca0..33f3dd247 100644 --- a/apps/api/src/app/budgets/budgets.controller.ts +++ b/apps/api/src/app/budgets/budgets.controller.ts @@ -1,7 +1,12 @@ 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 { + CreateBudgetDto, + CreateExpenseCategoryDto, + UpdateBudgetDto, + UpdateExpenseCategoryDto +} from '@ghostfolio/common/dtos'; import { BudgetResponse, BudgetsResponse, @@ -45,6 +50,28 @@ export class BudgetsController { }); } + @Post('categories') + @HasPermission(permissions.createExpenseCategory) + @UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) + public async createCategory( + @Body() data: CreateExpenseCategoryDto + ): Promise { + return this.budgetsService.createCategory({ + data, + userId: this.request.user.id + }); + } + + @Delete('categories/:id') + @HasPermission(permissions.deleteExpenseCategory) + @UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) + public async deleteCategory(@Param('id') id: string) { + return this.budgetsService.deleteCategory({ + id, + userId: this.request.user.id + }); + } + @Delete(':id') @HasPermission(permissions.deleteBudget) @UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) @@ -86,6 +113,20 @@ export class BudgetsController { }); } + @Put('categories/:id') + @HasPermission(permissions.updateExpenseCategory) + @UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) + public async updateCategory( + @Param('id') id: string, + @Body() data: UpdateExpenseCategoryDto + ): Promise { + return this.budgetsService.updateCategory({ + data, + id, + userId: this.request.user.id + }); + } + @Put(':id') @HasPermission(permissions.updateBudget) @UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) diff --git a/apps/api/src/app/budgets/budgets.service.spec.ts b/apps/api/src/app/budgets/budgets.service.spec.ts index 8f4ab6b27..c894c1faf 100644 --- a/apps/api/src/app/budgets/budgets.service.spec.ts +++ b/apps/api/src/app/budgets/budgets.service.spec.ts @@ -24,8 +24,11 @@ describe('BudgetsService', () => { groupBy: jest.Mock; }; expenseCategory: { + create: jest.Mock; + delete: jest.Mock; findFirst: jest.Mock; findMany: jest.Mock; + update: jest.Mock; }; }; @@ -42,8 +45,11 @@ describe('BudgetsService', () => { groupBy: jest.fn() }, expenseCategory: { + create: jest.fn(), + delete: jest.fn(), findFirst: jest.fn(), - findMany: jest.fn() + findMany: jest.fn(), + update: jest.fn() } }; @@ -160,6 +166,134 @@ describe('BudgetsService', () => { ]); }); + it('creates an expense category for the current user', async () => { + prismaService.expenseCategory.findFirst.mockResolvedValue(null); + prismaService.expenseCategory.create.mockResolvedValue({ + color: '#0055aa', + createdAt, + id: categoryId, + name: 'Groceries', + updatedAt, + userId + }); + + const response = await budgetsService.createCategory({ + data: { + color: '#0055aa', + name: 'Groceries' + }, + userId + }); + + expect(prismaService.expenseCategory.findFirst).toHaveBeenCalledWith({ + where: { + name: 'Groceries', + userId + } + }); + expect(prismaService.expenseCategory.create).toHaveBeenCalledWith({ + data: { + color: '#0055aa', + name: 'Groceries', + user: { connect: { id: userId } } + } + }); + expect(response).toEqual({ + color: '#0055aa', + createdAt, + id: categoryId, + name: 'Groceries', + updatedAt + }); + }); + + it('rejects duplicate category names for the current user', async () => { + prismaService.expenseCategory.findFirst.mockResolvedValue({ + id: categoryId, + name: 'Groceries', + userId + }); + + await expect( + budgetsService.createCategory({ + data: { + name: 'Groceries' + }, + userId + }) + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('updates only categories owned by the current user', async () => { + prismaService.expenseCategory.findFirst + .mockResolvedValueOnce({ + id: categoryId, + userId + }) + .mockResolvedValueOnce(null); + prismaService.expenseCategory.update.mockResolvedValue({ + color: '#aa5500', + createdAt, + id: categoryId, + name: 'Food', + updatedAt, + userId + }); + + const response = await budgetsService.updateCategory({ + data: { + color: '#aa5500', + id: categoryId, + name: 'Food' + }, + id: categoryId, + userId + }); + + expect(prismaService.expenseCategory.findFirst).toHaveBeenNthCalledWith(1, { + where: { + id: categoryId, + userId + } + }); + expect(prismaService.expenseCategory.findFirst).toHaveBeenNthCalledWith(2, { + where: { + name: 'Food', + userId + } + }); + expect(prismaService.expenseCategory.update).toHaveBeenCalledWith({ + data: { + color: '#aa5500', + name: 'Food' + }, + where: { id: categoryId } + }); + expect(response).toEqual({ + color: '#aa5500', + createdAt, + id: categoryId, + name: 'Food', + updatedAt + }); + }); + + it('deletes only categories owned by the current user', async () => { + prismaService.expenseCategory.findFirst.mockResolvedValue({ + id: categoryId, + userId + }); + prismaService.expenseCategory.delete.mockResolvedValue({ + id: categoryId + }); + + await budgetsService.deleteCategory({ id: categoryId, userId }); + + expect(prismaService.expenseCategory.delete).toHaveBeenCalledWith({ + where: { id: categoryId } + }); + }); + it('creates a budget for a category owned by the current user', async () => { prismaService.expenseCategory.findFirst.mockResolvedValue({ id: categoryId, diff --git a/apps/api/src/app/budgets/budgets.service.ts b/apps/api/src/app/budgets/budgets.service.ts index eb8114385..1e8f8e623 100644 --- a/apps/api/src/app/budgets/budgets.service.ts +++ b/apps/api/src/app/budgets/budgets.service.ts @@ -1,5 +1,10 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos'; +import { + CreateBudgetDto, + CreateExpenseCategoryDto, + UpdateBudgetDto, + UpdateExpenseCategoryDto +} from '@ghostfolio/common/dtos'; import { BudgetResponse, BudgetsResponse, @@ -16,6 +21,29 @@ import { export class BudgetsService { public constructor(private readonly prismaService: PrismaService) {} + public async createCategory({ + data, + userId + }: { + data: CreateExpenseCategoryDto; + userId: string; + }): Promise { + await this.validateCategoryNameAvailable({ + name: data.name, + userId + }); + + const category = await this.prismaService.expenseCategory.create({ + data: { + color: data.color, + name: data.name, + user: { connect: { id: userId } } + } + }); + + return this.toExpenseCategoryResponse(category); + } + public async createBudget({ data, userId @@ -64,6 +92,14 @@ export class BudgetsService { }); } + public async deleteCategory({ id, userId }: { id: string; userId: string }) { + await this.validateCategoryOwnership({ categoryId: id, userId }); + + return this.prismaService.expenseCategory.delete({ + where: { id } + }); + } + public async getBudget({ id, userId @@ -99,9 +135,9 @@ export class BudgetsService { where: { userId } }); - return categories.map(({ color, createdAt, id, name, updatedAt }) => { - return { color, createdAt, id, name, updatedAt }; - }); + return categories.map((category) => + this.toExpenseCategoryResponse(category) + ); } public async getBudgets({ @@ -197,6 +233,33 @@ export class BudgetsService { return this.toBudgetResponse({ budget, spent: 0 }); } + public async updateCategory({ + data, + id, + userId + }: { + data: UpdateExpenseCategoryDto; + id: string; + userId: string; + }): Promise { + await this.validateCategoryOwnership({ categoryId: id, userId }); + await this.validateCategoryNameAvailable({ + categoryId: id, + name: data.name, + userId + }); + + const category = await this.prismaService.expenseCategory.update({ + data: { + color: data.color, + name: data.name + }, + where: { id } + }); + + return this.toExpenseCategoryResponse(category); + } + private formatBudgetMonth(month: Date): string { return month.toISOString().slice(0, 7); } @@ -272,6 +335,22 @@ export class BudgetsService { }; } + private toExpenseCategoryResponse({ + color, + createdAt, + id, + name, + updatedAt + }: { + color?: string; + createdAt: Date; + id: string; + name: string; + updatedAt: Date; + }): ExpenseCategoryResponse { + return { color, createdAt, id, name, updatedAt }; + } + private async validateBudgetOwnership({ id, userId @@ -306,4 +385,27 @@ export class BudgetsService { throw new ForbiddenException(); } } + + private async validateCategoryNameAvailable({ + categoryId, + name, + userId + }: { + categoryId?: string; + name: string; + userId: string; + }) { + const existingCategory = await this.prismaService.expenseCategory.findFirst( + { + where: { + name, + userId + } + } + ); + + if (existingCategory && existingCategory.id !== categoryId) { + throw new ConflictException(); + } + } } diff --git a/libs/ui/src/lib/services/data.service.spec.ts b/libs/ui/src/lib/services/data.service.spec.ts index 0edd292c4..b121c3374 100644 --- a/libs/ui/src/lib/services/data.service.spec.ts +++ b/libs/ui/src/lib/services/data.service.spec.ts @@ -1,4 +1,9 @@ -import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos'; +import { + CreateBudgetDto, + CreateExpenseCategoryDto, + UpdateBudgetDto, + UpdateExpenseCategoryDto +} from '@ghostfolio/common/dtos'; import { provideHttpClient, @@ -86,6 +91,51 @@ describe('DataService budget methods', () => { expect(request.request.method).toBe('GET'); }); + it('creates an expense category', () => { + const category: CreateExpenseCategoryDto = { + color: '#0055aa', + name: 'Groceries' + }; + + dataService.createExpenseCategory(category).subscribe(); + + const request = httpTestingController.expectOne( + '/api/v1/budgets/categories' + ); + + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(category); + }); + + it('updates an expense category', () => { + const category: UpdateExpenseCategoryDto = { + color: '#aa5500', + id: 'category-1', + name: 'Food' + }; + + dataService + .updateExpenseCategory({ category, id: 'category-1' }) + .subscribe(); + + const request = httpTestingController.expectOne( + '/api/v1/budgets/categories/category-1' + ); + + expect(request.request.method).toBe('PUT'); + expect(request.request.body).toEqual(category); + }); + + it('deletes an expense category', () => { + dataService.deleteExpenseCategory('category-1').subscribe(); + + const request = httpTestingController.expectOne( + '/api/v1/budgets/categories/category-1' + ); + + expect(request.request.method).toBe('DELETE'); + }); + it('deletes a budget', () => { dataService.deleteBudget('budget-1').subscribe(); diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 2d69897ad..c6158cce2 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -3,6 +3,7 @@ import { CreateAccountBalanceDto, CreateAccountDto, CreateBudgetDto, + CreateExpenseCategoryDto, CreateOrderDto, CreateTagDto, CreateWatchlistItemDto, @@ -12,6 +13,7 @@ import { UpdateAccountDto, UpdateBudgetDto, UpdateBulkMarketDataDto, + UpdateExpenseCategoryDto, UpdateOrderDto, UpdateOwnAccessTokenDto, UpdatePropertyDto, @@ -202,10 +204,21 @@ export class DataService { return this.http.post('/api/v1/budgets', budget); } + public createExpenseCategory(category: CreateExpenseCategoryDto) { + return this.http.post( + '/api/v1/budgets/categories', + category + ); + } + public deleteBudget(id: string) { return this.http.delete(`/api/v1/budgets/${id}`); } + public deleteExpenseCategory(id: string) { + return this.http.delete(`/api/v1/budgets/categories/${id}`); + } + public fetchAccount(aAccountId: string) { return this.http.get(`/api/v1/account/${aAccountId}`); } @@ -968,6 +981,19 @@ export class DataService { return this.http.put(`/api/v1/budgets/${id}`, budget); } + public updateExpenseCategory({ + category, + id + }: { + category: UpdateExpenseCategoryDto; + id: string; + }) { + return this.http.put( + `/api/v1/budgets/categories/${id}`, + category + ); + } + public updateInfo() { this.http.get('/api/v1/info').subscribe((info) => { const utmSource = window.localStorage.getItem('utm_source') as