Browse Source

feat: add budget category management api

pull/7140/head
Yip Shing him 3 weeks ago
parent
commit
d198faab12
  1. 43
      apps/api/src/app/budgets/budgets.controller.ts
  2. 136
      apps/api/src/app/budgets/budgets.service.spec.ts
  3. 110
      apps/api/src/app/budgets/budgets.service.ts
  4. 52
      libs/ui/src/lib/services/data.service.spec.ts
  5. 26
      libs/ui/src/lib/services/data.service.ts

43
apps/api/src/app/budgets/budgets.controller.ts

@ -1,7 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { JwtOrApiKeyAuthGuard } from '@ghostfolio/api/guards/jwt-or-api-key-auth.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 { import {
BudgetResponse, BudgetResponse,
BudgetsResponse, BudgetsResponse,
@ -45,6 +50,28 @@ export class BudgetsController {
}); });
} }
@Post('categories')
@HasPermission(permissions.createExpenseCategory)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
public async createCategory(
@Body() data: CreateExpenseCategoryDto
): Promise<ExpenseCategoryResponse> {
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') @Delete(':id')
@HasPermission(permissions.deleteBudget) @HasPermission(permissions.deleteBudget)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) @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<ExpenseCategoryResponse> {
return this.budgetsService.updateCategory({
data,
id,
userId: this.request.user.id
});
}
@Put(':id') @Put(':id')
@HasPermission(permissions.updateBudget) @HasPermission(permissions.updateBudget)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard) @UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)

136
apps/api/src/app/budgets/budgets.service.spec.ts

@ -24,8 +24,11 @@ describe('BudgetsService', () => {
groupBy: jest.Mock; groupBy: jest.Mock;
}; };
expenseCategory: { expenseCategory: {
create: jest.Mock;
delete: jest.Mock;
findFirst: jest.Mock; findFirst: jest.Mock;
findMany: jest.Mock; findMany: jest.Mock;
update: jest.Mock;
}; };
}; };
@ -42,8 +45,11 @@ describe('BudgetsService', () => {
groupBy: jest.fn() groupBy: jest.fn()
}, },
expenseCategory: { expenseCategory: {
create: jest.fn(),
delete: jest.fn(),
findFirst: 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 () => { it('creates a budget for a category owned by the current user', async () => {
prismaService.expenseCategory.findFirst.mockResolvedValue({ prismaService.expenseCategory.findFirst.mockResolvedValue({
id: categoryId, id: categoryId,

110
apps/api/src/app/budgets/budgets.service.ts

@ -1,5 +1,10 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; 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 { import {
BudgetResponse, BudgetResponse,
BudgetsResponse, BudgetsResponse,
@ -16,6 +21,29 @@ import {
export class BudgetsService { export class BudgetsService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async createCategory({
data,
userId
}: {
data: CreateExpenseCategoryDto;
userId: string;
}): Promise<ExpenseCategoryResponse> {
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({ public async createBudget({
data, data,
userId 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({ public async getBudget({
id, id,
userId userId
@ -99,9 +135,9 @@ export class BudgetsService {
where: { userId } where: { userId }
}); });
return categories.map(({ color, createdAt, id, name, updatedAt }) => { return categories.map((category) =>
return { color, createdAt, id, name, updatedAt }; this.toExpenseCategoryResponse(category)
}); );
} }
public async getBudgets({ public async getBudgets({
@ -197,6 +233,33 @@ export class BudgetsService {
return this.toBudgetResponse({ budget, spent: 0 }); return this.toBudgetResponse({ budget, spent: 0 });
} }
public async updateCategory({
data,
id,
userId
}: {
data: UpdateExpenseCategoryDto;
id: string;
userId: string;
}): Promise<ExpenseCategoryResponse> {
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 { private formatBudgetMonth(month: Date): string {
return month.toISOString().slice(0, 7); 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({ private async validateBudgetOwnership({
id, id,
userId userId
@ -306,4 +385,27 @@ export class BudgetsService {
throw new ForbiddenException(); 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();
}
}
} }

52
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 { import {
provideHttpClient, provideHttpClient,
@ -86,6 +91,51 @@ describe('DataService budget methods', () => {
expect(request.request.method).toBe('GET'); 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', () => { it('deletes a budget', () => {
dataService.deleteBudget('budget-1').subscribe(); dataService.deleteBudget('budget-1').subscribe();

26
libs/ui/src/lib/services/data.service.ts

@ -3,6 +3,7 @@ import {
CreateAccountBalanceDto, CreateAccountBalanceDto,
CreateAccountDto, CreateAccountDto,
CreateBudgetDto, CreateBudgetDto,
CreateExpenseCategoryDto,
CreateOrderDto, CreateOrderDto,
CreateTagDto, CreateTagDto,
CreateWatchlistItemDto, CreateWatchlistItemDto,
@ -12,6 +13,7 @@ import {
UpdateAccountDto, UpdateAccountDto,
UpdateBudgetDto, UpdateBudgetDto,
UpdateBulkMarketDataDto, UpdateBulkMarketDataDto,
UpdateExpenseCategoryDto,
UpdateOrderDto, UpdateOrderDto,
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
UpdatePropertyDto, UpdatePropertyDto,
@ -202,10 +204,21 @@ export class DataService {
return this.http.post<BudgetResponse>('/api/v1/budgets', budget); return this.http.post<BudgetResponse>('/api/v1/budgets', budget);
} }
public createExpenseCategory(category: CreateExpenseCategoryDto) {
return this.http.post<ExpenseCategoryResponse>(
'/api/v1/budgets/categories',
category
);
}
public deleteBudget(id: string) { public deleteBudget(id: string) {
return this.http.delete<void>(`/api/v1/budgets/${id}`); return this.http.delete<void>(`/api/v1/budgets/${id}`);
} }
public deleteExpenseCategory(id: string) {
return this.http.delete<void>(`/api/v1/budgets/categories/${id}`);
}
public fetchAccount(aAccountId: string) { public fetchAccount(aAccountId: string) {
return this.http.get<AccountResponse>(`/api/v1/account/${aAccountId}`); return this.http.get<AccountResponse>(`/api/v1/account/${aAccountId}`);
} }
@ -968,6 +981,19 @@ export class DataService {
return this.http.put<BudgetResponse>(`/api/v1/budgets/${id}`, budget); return this.http.put<BudgetResponse>(`/api/v1/budgets/${id}`, budget);
} }
public updateExpenseCategory({
category,
id
}: {
category: UpdateExpenseCategoryDto;
id: string;
}) {
return this.http.put<ExpenseCategoryResponse>(
`/api/v1/budgets/categories/${id}`,
category
);
}
public updateInfo() { public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => { this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = window.localStorage.getItem('utm_source') as const utmSource = window.localStorage.getItem('utm_source') as

Loading…
Cancel
Save