From 53d8de30b55c58ed3f5fb2dc617e39b19e0f53ce Mon Sep 17 00:00:00 2001 From: Yip Shing him Date: Tue, 23 Jun 2026 18:42:18 +0100 Subject: [PATCH] feat: add budget foundation --- apps/api/src/app/app.module.ts | 2 + .../api/src/app/budgets/budgets.controller.ts | 89 ++ apps/api/src/app/budgets/budgets.module.ts | 13 + .../src/app/budgets/budgets.service.spec.ts | 285 +++++++ apps/api/src/app/budgets/budgets.service.ts | 290 +++++++ .../src/guards/jwt-or-api-key-auth.guard.ts | 16 + .../plans/2026-06-23-budget-feature.md | 557 ++++++++++++ .../plans/2026-06-23-expenses-feature.md | 792 ++++++++++++++++++ libs/common/src/lib/dtos/create-budget.dto.ts | 18 + .../lib/dtos/create-expense-category.dto.ts | 10 + .../common/src/lib/dtos/create-expense.dto.ts | 42 + libs/common/src/lib/dtos/index.ts | 12 + libs/common/src/lib/dtos/update-budget.dto.ts | 8 + .../lib/dtos/update-expense-category.dto.ts | 13 + .../common/src/lib/dtos/update-expense.dto.ts | 8 + libs/common/src/lib/interfaces/index.ts | 14 + .../responses/budget-response.interface.ts | 21 + .../expense-category-response.interface.ts | 7 + .../responses/expenses-response.interface.ts | 21 + libs/common/src/lib/permissions.spec.ts | 31 + libs/common/src/lib/permissions.ts | 36 + prisma/schema.prisma | 109 ++- 22 files changed, 2372 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/app/budgets/budgets.controller.ts create mode 100644 apps/api/src/app/budgets/budgets.module.ts create mode 100644 apps/api/src/app/budgets/budgets.service.spec.ts create mode 100644 apps/api/src/app/budgets/budgets.service.ts create mode 100644 apps/api/src/guards/jwt-or-api-key-auth.guard.ts create mode 100644 docs/superpowers/plans/2026-06-23-budget-feature.md create mode 100644 docs/superpowers/plans/2026-06-23-expenses-feature.md create mode 100644 libs/common/src/lib/dtos/create-budget.dto.ts create mode 100644 libs/common/src/lib/dtos/create-expense-category.dto.ts create mode 100644 libs/common/src/lib/dtos/create-expense.dto.ts create mode 100644 libs/common/src/lib/dtos/update-budget.dto.ts create mode 100644 libs/common/src/lib/dtos/update-expense-category.dto.ts create mode 100644 libs/common/src/lib/dtos/update-expense.dto.ts create mode 100644 libs/common/src/lib/interfaces/responses/budget-response.interface.ts create mode 100644 libs/common/src/lib/interfaces/responses/expense-category-response.interface.ts create mode 100644 libs/common/src/lib/interfaces/responses/expenses-response.interface.ts create mode 100644 libs/common/src/lib/permissions.spec.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 0a27faa64..98dbc7a0e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -35,6 +35,7 @@ import { AppController } from './app.controller'; import { AssetModule } from './asset/asset.module'; import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; +import { BudgetsModule } from './budgets/budgets.module'; import { CacheModule } from './cache/cache.module'; import { AiModule } from './endpoints/ai/ai.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; @@ -76,6 +77,7 @@ import { UserModule } from './user/user.module'; AuthDeviceModule, AuthModule, BenchmarksModule, + BudgetsModule, BullBoardModule.forRoot({ adapter: ExpressAdapter, boardOptions: { diff --git a/apps/api/src/app/budgets/budgets.controller.ts b/apps/api/src/app/budgets/budgets.controller.ts new file mode 100644 index 000000000..4d86480a4 --- /dev/null +++ b/apps/api/src/app/budgets/budgets.controller.ts @@ -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 { + 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 { + 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 { + 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 { + return this.budgetsService.updateBudget({ + data, + id, + userId: this.request.user.id + }); + } +} diff --git a/apps/api/src/app/budgets/budgets.module.ts b/apps/api/src/app/budgets/budgets.module.ts new file mode 100644 index 000000000..f2e3b67ce --- /dev/null +++ b/apps/api/src/app/budgets/budgets.module.ts @@ -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 {} diff --git a/apps/api/src/app/budgets/budgets.service.spec.ts b/apps/api/src/app/budgets/budgets.service.spec.ts new file mode 100644 index 000000000..557c05da7 --- /dev/null +++ b/apps/api/src/app/budgets/budgets.service.spec.ts @@ -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 } + }); + }); +}); diff --git a/apps/api/src/app/budgets/budgets.service.ts b/apps/api/src/app/budgets/budgets.service.ts new file mode 100644 index 000000000..577c883a1 --- /dev/null +++ b/apps/api/src/app/budgets/budgets.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + } + } +} diff --git a/apps/api/src/guards/jwt-or-api-key-auth.guard.ts b/apps/api/src/guards/jwt-or-api-key-auth.guard.ts new file mode 100644 index 000000000..d7ef98bcb --- /dev/null +++ b/apps/api/src/guards/jwt-or-api-key-auth.guard.ts @@ -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 { + try { + return (await this.jwtGuard.canActivate(context)) as boolean; + } catch { + return (await this.apiKeyGuard.canActivate(context)) as boolean; + } + } +} diff --git a/docs/superpowers/plans/2026-06-23-budget-feature.md b/docs/superpowers/plans/2026-06-23-budget-feature.md new file mode 100644 index 000000000..c3fb64e2a --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-budget-feature.md @@ -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 `; 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('/api/v1/budgets', { params }); +} + +public postBudget(aBudget: CreateBudgetDto) { + return this.http.post('/api/v1/budgets', aBudget); +} + +public putBudget(aBudget: UpdateBudgetDto) { + return this.http.put(`/api/v1/budgets/${aBudget.id}`, aBudget); +} + +public deleteBudget(aId: string) { + return this.http.delete(`/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. diff --git a/docs/superpowers/plans/2026-06-23-expenses-feature.md b/docs/superpowers/plans/2026-06-23-expenses-feature.md new file mode 100644 index 000000000..244786db9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-expenses-feature.md @@ -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 `; 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 { + 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('/api/v1/expense-categories'); +} + +public postExpenseCategory(aCategory: CreateExpenseCategoryDto) { + return this.http.post('/api/v1/expense-categories', aCategory); +} + +public putExpenseCategory(aCategory: UpdateExpenseCategoryDto) { + return this.http.put( + `/api/v1/expense-categories/${aCategory.id}`, + aCategory + ); +} + +public deleteExpenseCategory(aId: string) { + return this.http.delete(`/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('/api/v1/expenses', { params }); +} + +public postExpense(aExpense: CreateExpenseDto) { + return this.http.post('/api/v1/expenses', aExpense); +} + +public putExpense(aExpense: UpdateExpenseDto) { + return this.http.put(`/api/v1/expenses/${aExpense.id}`, aExpense); +} + +public deleteExpense(aId: string) { + return this.http.delete(`/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. diff --git a/libs/common/src/lib/dtos/create-budget.dto.ts b/libs/common/src/lib/dtos/create-budget.dto.ts new file mode 100644 index 000000000..a58a25afa --- /dev/null +++ b/libs/common/src/lib/dtos/create-budget.dto.ts @@ -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; +} diff --git a/libs/common/src/lib/dtos/create-expense-category.dto.ts b/libs/common/src/lib/dtos/create-expense-category.dto.ts new file mode 100644 index 000000000..5a9dd5fea --- /dev/null +++ b/libs/common/src/lib/dtos/create-expense-category.dto.ts @@ -0,0 +1,10 @@ +import { IsHexColor, IsOptional, IsString } from 'class-validator'; + +export class CreateExpenseCategoryDto { + @IsString() + name: string; + + @IsHexColor() + @IsOptional() + color?: string; +} diff --git a/libs/common/src/lib/dtos/create-expense.dto.ts b/libs/common/src/lib/dtos/create-expense.dto.ts new file mode 100644 index 000000000..a630e7755 --- /dev/null +++ b/libs/common/src/lib/dtos/create-expense.dto.ts @@ -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[]; +} diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts index cf0ce6f57..fdd967002 100644 --- a/libs/common/src/lib/dtos/index.ts +++ b/libs/common/src/lib/dtos/index.ts @@ -5,6 +5,9 @@ import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto import { CreateAccountDto } from './create-account.dto'; import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; import { CreateAssetProfileDto } from './create-asset-profile.dto'; +import { CreateBudgetDto } from './create-budget.dto'; +import { CreateExpenseCategoryDto } from './create-expense-category.dto'; +import { CreateExpenseDto } from './create-expense.dto'; import { CreateOrderDto } from './create-order.dto'; import { CreatePlatformDto } from './create-platform.dto'; import { CreateTagDto } from './create-tag.dto'; @@ -15,7 +18,10 @@ import { UpdateAccessDto } from './update-access.dto'; import { UpdateAccountDto } from './update-account.dto'; import { UpdateAssetProfileDataDto } from './update-asset-profile-data.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto'; +import { UpdateBudgetDto } from './update-budget.dto'; import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto'; +import { UpdateExpenseCategoryDto } from './update-expense-category.dto'; +import { UpdateExpenseDto } from './update-expense.dto'; import { UpdateMarketDataDto } from './update-market-data.dto'; import { UpdateOrderDto } from './update-order.dto'; import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto'; @@ -32,6 +38,9 @@ export { CreateAccountWithBalancesDto, CreateAssetProfileDto, CreateAssetProfileWithMarketDataDto, + CreateBudgetDto, + CreateExpenseCategoryDto, + CreateExpenseDto, CreateOrderDto, CreatePlatformDto, CreateTagDto, @@ -42,7 +51,10 @@ export { UpdateAccountDto, UpdateAssetProfileDataDto, UpdateAssetProfileDto, + UpdateBudgetDto, UpdateBulkMarketDataDto, + UpdateExpenseCategoryDto, + UpdateExpenseDto, UpdateMarketDataDto, UpdateOrderDto, UpdateOwnAccessTokenDto, diff --git a/libs/common/src/lib/dtos/update-budget.dto.ts b/libs/common/src/lib/dtos/update-budget.dto.ts new file mode 100644 index 000000000..7e598e3e2 --- /dev/null +++ b/libs/common/src/lib/dtos/update-budget.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator'; + +import { CreateBudgetDto } from './create-budget.dto'; + +export class UpdateBudgetDto extends CreateBudgetDto { + @IsString() + id: string; +} diff --git a/libs/common/src/lib/dtos/update-expense-category.dto.ts b/libs/common/src/lib/dtos/update-expense-category.dto.ts new file mode 100644 index 000000000..aa101f3d7 --- /dev/null +++ b/libs/common/src/lib/dtos/update-expense-category.dto.ts @@ -0,0 +1,13 @@ +import { IsHexColor, IsOptional, IsString } from 'class-validator'; + +export class UpdateExpenseCategoryDto { + @IsString() + id: string; + + @IsString() + name: string; + + @IsHexColor() + @IsOptional() + color?: string; +} diff --git a/libs/common/src/lib/dtos/update-expense.dto.ts b/libs/common/src/lib/dtos/update-expense.dto.ts new file mode 100644 index 000000000..33c5beab5 --- /dev/null +++ b/libs/common/src/lib/dtos/update-expense.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator'; + +import { CreateExpenseDto } from './create-expense.dto'; + +export class UpdateExpenseDto extends CreateExpenseDto { + @IsString() + id: string; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 6777f8ac6..e3f67799e 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -52,6 +52,10 @@ import type { AssetProfilesResponse } from './responses/asset-profiles-response. import type { AssetResponse } from './responses/asset-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface'; +import type { + BudgetResponse, + BudgetsResponse +} from './responses/budget-response.interface'; import type { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface'; import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface'; import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface'; @@ -63,6 +67,11 @@ import type { } from './responses/data-provider-response.interface'; import type { DividendsResponse } from './responses/dividends-response.interface'; import type { ResponseError } from './responses/errors.interface'; +import type { ExpenseCategoryResponse } from './responses/expense-category-response.interface'; +import type { + ExpenseResponse, + ExpensesResponse +} from './responses/expenses-response.interface'; import type { ExportResponse } from './responses/export-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface'; import type { ImportResponse } from './responses/import-response.interface'; @@ -131,6 +140,8 @@ export { BenchmarkMarketDataDetailsResponse, BenchmarkProperty, BenchmarkResponse, + BudgetResponse, + BudgetsResponse, Coupon, CreateStripeCheckoutSessionResponse, DataEnhancerHealthResponse, @@ -142,6 +153,9 @@ export { DataProviderResponse, DividendsResponse, EnhancedSymbolProfile, + ExpenseCategoryResponse, + ExpenseResponse, + ExpensesResponse, ExportResponse, Filter, FilterGroup, diff --git a/libs/common/src/lib/interfaces/responses/budget-response.interface.ts b/libs/common/src/lib/interfaces/responses/budget-response.interface.ts new file mode 100644 index 000000000..ec4cd9ea4 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/budget-response.interface.ts @@ -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; +} diff --git a/libs/common/src/lib/interfaces/responses/expense-category-response.interface.ts b/libs/common/src/lib/interfaces/responses/expense-category-response.interface.ts new file mode 100644 index 000000000..8595e4d7b --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/expense-category-response.interface.ts @@ -0,0 +1,7 @@ +export interface ExpenseCategoryResponse { + color?: string; + createdAt: Date; + id: string; + name: string; + updatedAt: Date; +} diff --git a/libs/common/src/lib/interfaces/responses/expenses-response.interface.ts b/libs/common/src/lib/interfaces/responses/expenses-response.interface.ts new file mode 100644 index 000000000..a696eec4d --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/expenses-response.interface.ts @@ -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[]; +} diff --git a/libs/common/src/lib/permissions.spec.ts b/libs/common/src/lib/permissions.spec.ts new file mode 100644 index 000000000..fede82be5 --- /dev/null +++ b/libs/common/src/lib/permissions.spec.ts @@ -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); + } + }); +}); diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index f9cb19562..67773ac8f 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -12,6 +12,9 @@ export const permissions = { createAccountBalance: 'createAccountBalance', createActivity: 'createActivity', createApiKey: 'createApiKey', + createBudget: 'createBudget', + createExpense: 'createExpense', + createExpenseCategory: 'createExpenseCategory', createMarketData: 'createMarketData', createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile', createOwnTag: 'createOwnTag', @@ -24,6 +27,9 @@ export const permissions = { deleteAccountBalance: 'deleteAccountBalance', deleteActivity: 'deleteActivity', deleteAuthDevice: 'deleteAuthDevice', + deleteBudget: 'deleteBudget', + deleteExpense: 'deleteExpense', + deleteExpenseCategory: 'deleteExpenseCategory', deleteOwnUser: 'deleteOwnUser', deletePlatform: 'deletePlatform', deleteTag: 'deleteTag', @@ -42,6 +48,9 @@ export const permissions = { enableSystemMessage: 'enableSystemMessage', impersonateAllUsers: 'impersonateAllUsers', readAiPrompt: 'readAiPrompt', + readBudgets: 'readBudgets', + readExpenses: 'readExpenses', + readExpenseCategories: 'readExpenseCategories', readMarketData: 'readMarketData', readMarketDataOfMarkets: 'readMarketDataOfMarkets', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', @@ -56,6 +65,9 @@ export const permissions = { updateAccess: 'updateAccess', updateActivity: 'updateActivity', updateAuthDevice: 'updateAuthDevice', + updateBudget: 'updateBudget', + updateExpense: 'updateExpense', + updateExpenseCategory: 'updateExpenseCategory', updateMarketData: 'updateMarketData', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateOwnAccessToken: 'updateOwnAccessToken', @@ -76,6 +88,9 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createAccountBalance, permissions.createActivity, + permissions.createBudget, + permissions.createExpense, + permissions.createExpenseCategory, permissions.createWatchlistItem, permissions.deleteAccountBalance, permissions.deleteWatchlistItem, @@ -88,10 +103,16 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteActivity, permissions.deleteAuthDevice, + permissions.deleteBudget, + permissions.deleteExpense, + permissions.deleteExpenseCategory, permissions.deletePlatform, permissions.deleteTag, permissions.deleteUser, permissions.readAiPrompt, + permissions.readBudgets, + permissions.readExpenses, + permissions.readExpenseCategories, permissions.readMarketData, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, @@ -102,6 +123,9 @@ export function getPermissions(aRole: Role): string[] { permissions.updateAccess, permissions.updateActivity, permissions.updateAuthDevice, + permissions.updateBudget, + permissions.updateExpense, + permissions.updateExpenseCategory, permissions.updateMarketData, permissions.updateMarketDataOfOwnAssetProfile, permissions.updatePlatform, @@ -127,6 +151,9 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createAccountBalance, permissions.createActivity, + permissions.createBudget, + permissions.createExpense, + permissions.createExpenseCategory, permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, permissions.createWatchlistItem, @@ -135,8 +162,14 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccountBalance, permissions.deleteActivity, permissions.deleteAuthDevice, + permissions.deleteBudget, + permissions.deleteExpense, + permissions.deleteExpenseCategory, permissions.deleteWatchlistItem, permissions.readAiPrompt, + permissions.readBudgets, + permissions.readExpenses, + permissions.readExpenseCategories, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, permissions.readWatchlist, @@ -144,6 +177,9 @@ export function getPermissions(aRole: Role): string[] { permissions.updateAccess, permissions.updateActivity, permissions.updateAuthDevice, + permissions.updateBudget, + permissions.updateExpense, + permissions.updateExpenseCategory, permissions.updateMarketDataOfOwnAssetProfile, permissions.updateUserSettings, permissions.updateViewMode diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c39667ee..6b0aa770f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,7 @@ model Account { comment String? createdAt DateTime @default(now()) currency String? + expenses Expense[] id String @default(uuid()) isExcluded Boolean @default(false) name String? @@ -115,6 +116,66 @@ model AuthDevice { @@index([userId]) } +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]) +} + +model ExpenseCategory { + budgets Budget[] + 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]) +} + model MarketData { createdAt DateTime @default(now()) dataSource DataSource @@ -252,9 +313,10 @@ model Subscription { model Tag { activities Order[] - id String @id @default(uuid()) + expenses Expense[] + id String @id @default(uuid()) name String - user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) + user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) userId String? @@unique([name, userId]) @@ -262,26 +324,29 @@ model Tag { } model User { - accessesGet Access[] @relation("accessGet") - accessesGive Access[] @relation("accessGive") - accessToken String? - accounts Account[] - activities Order[] - analytics Analytics? - apiKeys ApiKey[] - authChallenge String? - authDevices AuthDevice[] - createdAt DateTime @default(now()) - id String @id @default(uuid()) - provider Provider @default(ANONYMOUS) - role Role @default(USER) - settings Settings? - subscriptions Subscription[] - tags Tag[] - thirdPartyId String? - updatedAt DateTime @updatedAt - watchlist SymbolProfile[] @relation("UserWatchlist") - SymbolProfile SymbolProfile[] + accessesGet Access[] @relation("accessGet") + accessesGive Access[] @relation("accessGive") + accessToken String? + accounts Account[] + activities Order[] + analytics Analytics? + apiKeys ApiKey[] + authChallenge String? + authDevices AuthDevice[] + budgets Budget[] + createdAt DateTime @default(now()) + expenseCategories ExpenseCategory[] + expenses Expense[] + id String @id @default(uuid()) + provider Provider @default(ANONYMOUS) + role Role @default(USER) + settings Settings? + subscriptions Subscription[] + tags Tag[] + thirdPartyId String? + updatedAt DateTime @updatedAt + watchlist SymbolProfile[] @relation("UserWatchlist") + SymbolProfile SymbolProfile[] @@index([accessToken]) @@index([createdAt])