diff --git a/apps/api/src/app/budgets/budgets.controller.ts b/apps/api/src/app/budgets/budgets.controller.ts index 4d86480a4..d9f64fca0 100644 --- a/apps/api/src/app/budgets/budgets.controller.ts +++ b/apps/api/src/app/budgets/budgets.controller.ts @@ -2,7 +2,11 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat 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 { + BudgetResponse, + BudgetsResponse, + ExpenseCategoryResponse +} from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -63,6 +67,15 @@ export class BudgetsController { }); } + @Get('categories') + @HasPermission(permissions.readExpenseCategories) + @UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) + public async getCategories(): Promise { + return this.budgetsService.getCategories({ + userId: this.request.user.id + }); + } + @Get(':id') @HasPermission(permissions.readBudgets) @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 557c05da7..8f4ab6b27 100644 --- a/apps/api/src/app/budgets/budgets.service.spec.ts +++ b/apps/api/src/app/budgets/budgets.service.spec.ts @@ -25,6 +25,7 @@ describe('BudgetsService', () => { }; expenseCategory: { findFirst: jest.Mock; + findMany: jest.Mock; }; }; @@ -41,7 +42,8 @@ describe('BudgetsService', () => { groupBy: jest.fn() }, expenseCategory: { - findFirst: jest.fn() + findFirst: jest.fn(), + findMany: jest.fn() } }; @@ -129,6 +131,35 @@ describe('BudgetsService', () => { }); }); + it('lists expense categories for the current user', async () => { + prismaService.expenseCategory.findMany.mockResolvedValue([ + { + color: '#0055aa', + createdAt, + id: categoryId, + name: 'Groceries', + updatedAt, + userId + } + ]); + + const response = await budgetsService.getCategories({ userId }); + + expect(prismaService.expenseCategory.findMany).toHaveBeenCalledWith({ + orderBy: { name: 'asc' }, + where: { userId } + }); + expect(response).toEqual([ + { + color: '#0055aa', + createdAt, + id: categoryId, + name: 'Groceries', + updatedAt + } + ]); + }); + 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 577c883a1..eb8114385 100644 --- a/apps/api/src/app/budgets/budgets.service.ts +++ b/apps/api/src/app/budgets/budgets.service.ts @@ -1,6 +1,10 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos'; -import { BudgetResponse, BudgetsResponse } from '@ghostfolio/common/interfaces'; +import { + BudgetResponse, + BudgetsResponse, + ExpenseCategoryResponse +} from '@ghostfolio/common/interfaces'; import { ConflictException, @@ -85,6 +89,21 @@ export class BudgetsService { return this.toBudgetResponse({ budget, spent }); } + public async getCategories({ + userId + }: { + userId: string; + }): Promise { + const categories = await this.prismaService.expenseCategory.findMany({ + orderBy: { name: 'asc' }, + where: { userId } + }); + + return categories.map(({ color, createdAt, id, name, updatedAt }) => { + return { color, createdAt, id, name, updatedAt }; + }); + } + public async getBudgets({ month, userId diff --git a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts index b0a7d5851..1d2240cad 100644 --- a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts +++ b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts @@ -13,7 +13,10 @@ import { describe('GfCreateOrUpdateBudgetDialogComponent', () => { let component: GfCreateOrUpdateBudgetDialogComponent; let dataService: jest.Mocked< - Pick + Pick< + DataService, + 'createBudget' | 'fetchExpenseCategories' | 'updateBudget' + > >; let dialogRef: jest.Mocked< Pick, 'close'> @@ -32,6 +35,17 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => { spent: 0 }) ), + fetchExpenseCategories: jest.fn().mockReturnValue( + of([ + { + color: '#0055aa', + createdAt: new Date('2026-06-01'), + id: 'food', + name: 'Food', + updatedAt: new Date('2026-06-01') + } + ]) + ), updateBudget: jest.fn().mockReturnValue( of({ amount: 650, @@ -86,6 +100,7 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => { component.onSubmit(); await fixture.whenStable(); + expect(dataService.fetchExpenseCategories).toHaveBeenCalled(); expect(dataService.createBudget).toHaveBeenCalledWith({ amount: 500, categoryId: 'food', diff --git a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts index 0f528b4aa..11442d642 100644 --- a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts @@ -1,9 +1,13 @@ import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos'; -import { BudgetResponse } from '@ghostfolio/common/interfaces'; +import { + BudgetResponse, + ExpenseCategoryResponse +} from '@ghostfolio/common/interfaces'; import { DataService } from '@ghostfolio/ui/services'; import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; +import { Component, DestroyRef, Inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, @@ -18,6 +22,7 @@ import { } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; export interface CreateOrUpdateBudgetDialogData { budget?: BudgetResponse; @@ -32,13 +37,14 @@ export interface CreateOrUpdateBudgetDialogData { MatDialogModule, MatFormFieldModule, MatInputModule, + MatSelectModule, ReactiveFormsModule ], selector: 'gf-create-or-update-budget-dialog', styleUrls: ['./create-or-update-budget-dialog.scss'], templateUrl: './create-or-update-budget-dialog.html' }) -export class GfCreateOrUpdateBudgetDialogComponent { +export class GfCreateOrUpdateBudgetDialogComponent implements OnInit { public budgetForm = new FormGroup({ amount: new FormControl(this.data.budget?.amount ?? 0, { nonNullable: true, @@ -53,13 +59,26 @@ export class GfCreateOrUpdateBudgetDialogComponent { validators: [Validators.required] }) }); + public categories: ExpenseCategoryResponse[] = []; + public isLoadingCategories = true; public constructor( @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateBudgetDialogData, private dataService: DataService, + private destroyRef: DestroyRef, private dialogRef: MatDialogRef ) {} + public ngOnInit() { + this.dataService + .fetchExpenseCategories() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((categories) => { + this.categories = categories; + this.isLoadingCategories = false; + }); + } + public onSubmit() { if (this.budgetForm.invalid) { this.budgetForm.markAllAsTouched(); diff --git a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html index c525b0fc4..267b92b16 100644 --- a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html +++ b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html @@ -14,8 +14,20 @@ - Category id - + Category + + @if (isLoadingCategories) { + Loading categories... + } @else if (categories.length === 0) { + No expense categories available + } @else { + @for (category of categories; track category.id) { + + {{ category.name }} + + } + } + diff --git a/libs/ui/src/lib/services/data.service.spec.ts b/libs/ui/src/lib/services/data.service.spec.ts index 98c85f2f9..0edd292c4 100644 --- a/libs/ui/src/lib/services/data.service.spec.ts +++ b/libs/ui/src/lib/services/data.service.spec.ts @@ -47,6 +47,7 @@ describe('DataService budget methods', () => { const budget: CreateBudgetDto = { amount: 500, categoryId: 'category-1', + currency: 'USD', month: '2026-06' }; @@ -62,6 +63,8 @@ describe('DataService budget methods', () => { const budget: UpdateBudgetDto = { amount: 650, categoryId: 'category-1', + currency: 'USD', + id: 'budget-1', month: '2026-06' }; @@ -73,6 +76,16 @@ describe('DataService budget methods', () => { expect(request.request.body).toEqual(budget); }); + it('fetches expense categories', () => { + dataService.fetchExpenseCategories().subscribe(); + + const request = httpTestingController.expectOne( + '/api/v1/budgets/categories' + ); + + expect(request.request.method).toBe('GET'); + }); + 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 878e554c5..2d69897ad 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -40,6 +40,7 @@ import { CreateStripeCheckoutSessionResponse, DataProviderHealthResponse, DataProviderHistoricalResponse, + ExpenseCategoryResponse, ExportResponse, Filter, ImportResponse, @@ -401,6 +402,12 @@ export class DataService { return this.http.get('/api/v1/budgets', { params }); } + public fetchExpenseCategories() { + return this.http.get( + '/api/v1/budgets/categories' + ); + } + public fetchAssetProfiles({ filters, skip,