Browse Source

feat: add budget foundation

pull/7140/head
Yip Shing him 3 weeks ago
parent
commit
53d8de30b5
  1. 2
      apps/api/src/app/app.module.ts
  2. 89
      apps/api/src/app/budgets/budgets.controller.ts
  3. 13
      apps/api/src/app/budgets/budgets.module.ts
  4. 285
      apps/api/src/app/budgets/budgets.service.spec.ts
  5. 290
      apps/api/src/app/budgets/budgets.service.ts
  6. 16
      apps/api/src/guards/jwt-or-api-key-auth.guard.ts
  7. 557
      docs/superpowers/plans/2026-06-23-budget-feature.md
  8. 792
      docs/superpowers/plans/2026-06-23-expenses-feature.md
  9. 18
      libs/common/src/lib/dtos/create-budget.dto.ts
  10. 10
      libs/common/src/lib/dtos/create-expense-category.dto.ts
  11. 42
      libs/common/src/lib/dtos/create-expense.dto.ts
  12. 12
      libs/common/src/lib/dtos/index.ts
  13. 8
      libs/common/src/lib/dtos/update-budget.dto.ts
  14. 13
      libs/common/src/lib/dtos/update-expense-category.dto.ts
  15. 8
      libs/common/src/lib/dtos/update-expense.dto.ts
  16. 14
      libs/common/src/lib/interfaces/index.ts
  17. 21
      libs/common/src/lib/interfaces/responses/budget-response.interface.ts
  18. 7
      libs/common/src/lib/interfaces/responses/expense-category-response.interface.ts
  19. 21
      libs/common/src/lib/interfaces/responses/expenses-response.interface.ts
  20. 31
      libs/common/src/lib/permissions.spec.ts
  21. 36
      libs/common/src/lib/permissions.ts
  22. 65
      prisma/schema.prisma

2
apps/api/src/app/app.module.ts

@ -35,6 +35,7 @@ import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BudgetsModule } from './budgets/budgets.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
@ -76,6 +77,7 @@ import { UserModule } from './user/user.module';
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarksModule, BenchmarksModule,
BudgetsModule,
BullBoardModule.forRoot({ BullBoardModule.forRoot({
adapter: ExpressAdapter, adapter: ExpressAdapter,
boardOptions: { boardOptions: {

89
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<BudgetResponse> {
return this.budgetsService.createBudget({
data,
userId: this.request.user.id
});
}
@Delete(':id')
@HasPermission(permissions.deleteBudget)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
public async deleteBudget(@Param('id') id: string) {
return this.budgetsService.deleteBudget({
id,
userId: this.request.user.id
});
}
@Get()
@HasPermission(permissions.readBudgets)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
public async getBudgets(
@Query('month') month: string
): Promise<BudgetsResponse> {
return this.budgetsService.getBudgets({
month,
userId: this.request.user.id
});
}
@Get(':id')
@HasPermission(permissions.readBudgets)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
public async getBudget(@Param('id') id: string): Promise<BudgetResponse> {
return this.budgetsService.getBudget({
id,
userId: this.request.user.id
});
}
@Put(':id')
@HasPermission(permissions.updateBudget)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
public async updateBudget(
@Param('id') id: string,
@Body() data: UpdateBudgetDto
): Promise<BudgetResponse> {
return this.budgetsService.updateBudget({
data,
id,
userId: this.request.user.id
});
}
}

13
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 {}

285
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 }
});
});
});

290
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<BudgetResponse> {
await this.validateCategoryOwnership({
categoryId: data.categoryId,
userId
});
const month = this.parseBudgetMonth(data.month);
const existingBudget = await this.prismaService.budget.findFirst({
where: {
categoryId: data.categoryId,
month,
userId
}
});
if (existingBudget) {
throw new ConflictException();
}
const budget = await this.prismaService.budget.create({
data: {
amount: data.amount,
category: { connect: { id: data.categoryId } },
currency: data.currency,
month,
user: { connect: { id: userId } }
},
include: { category: true }
});
return this.toBudgetResponse({ budget, spent: 0 });
}
public async deleteBudget({ id, userId }: { id: string; userId: string }) {
await this.validateBudgetOwnership({ id, userId });
return this.prismaService.budget.delete({
where: { id }
});
}
public async getBudget({
id,
userId
}: {
id: string;
userId: string;
}): Promise<BudgetResponse> {
const budget = await this.prismaService.budget.findFirst({
include: { category: true },
where: { id, userId }
});
if (!budget) {
throw new ForbiddenException();
}
const spent = await this.getSpentForBudget({
categoryId: budget.categoryId,
month: budget.month,
userId
});
return this.toBudgetResponse({ budget, spent });
}
public async getBudgets({
month,
userId
}: {
month: string;
userId: string;
}): Promise<BudgetsResponse> {
const budgetMonth = this.parseBudgetMonth(month);
const nextMonth = this.getNextMonth(budgetMonth);
const budgets = await this.prismaService.budget.findMany({
include: { category: true },
orderBy: { category: { name: 'asc' } },
where: {
month: budgetMonth,
userId
}
});
const categoryIds = budgets.map(({ categoryId }) => categoryId);
const expenseSums = categoryIds.length
? await this.prismaService.expense.groupBy({
by: ['categoryId'],
where: {
categoryId: { in: categoryIds },
date: {
gte: budgetMonth,
lt: nextMonth
},
userId
},
_sum: { amount: true }
})
: [];
const spentByCategoryId = new Map(
expenseSums.map(({ _sum, categoryId }) => {
return [categoryId, _sum.amount ?? 0];
})
);
const budgetResponses = budgets.map((budget) => {
return this.toBudgetResponse({
budget,
spent: spentByCategoryId.get(budget.categoryId) ?? 0
});
});
return {
budgets: budgetResponses,
totalBudgeted: budgetResponses.reduce((sum, { amount }) => {
return sum + amount;
}, 0),
totalRemaining: budgetResponses.reduce((sum, { remaining }) => {
return sum + remaining;
}, 0),
totalSpent: budgetResponses.reduce((sum, { spent }) => {
return sum + spent;
}, 0)
};
}
public async updateBudget({
data,
id,
userId
}: {
data: UpdateBudgetDto;
id: string;
userId: string;
}): Promise<BudgetResponse> {
await this.validateBudgetOwnership({ id, userId });
await this.validateCategoryOwnership({
categoryId: data.categoryId,
userId
});
const month = this.parseBudgetMonth(data.month);
const budget = await this.prismaService.budget.update({
data: {
amount: data.amount,
category: { connect: { id: data.categoryId } },
currency: data.currency,
month
},
include: { category: true },
where: { id }
});
return this.toBudgetResponse({ budget, spent: 0 });
}
private formatBudgetMonth(month: Date): string {
return month.toISOString().slice(0, 7);
}
private getNextMonth(month: Date): Date {
return new Date(
Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1)
);
}
private async getSpentForBudget({
categoryId,
month,
userId
}: {
categoryId: string;
month: Date;
userId: string;
}) {
const [expenseSum] = await this.prismaService.expense.groupBy({
by: ['categoryId'],
where: {
categoryId: { in: [categoryId] },
date: {
gte: month,
lt: this.getNextMonth(month)
},
userId
},
_sum: { amount: true }
});
return expenseSum?._sum.amount ?? 0;
}
private parseBudgetMonth(month: string): Date {
return new Date(`${month}-01T00:00:00.000Z`);
}
private toBudgetResponse({
budget,
spent
}: {
budget: {
amount: number;
category: {
color?: string;
createdAt: Date;
id: string;
name: string;
updatedAt: Date;
};
categoryId: string;
createdAt: Date;
currency: string;
id: string;
month: Date;
updatedAt: Date;
};
spent: number;
}): BudgetResponse {
return {
amount: budget.amount,
category: budget.category,
categoryId: budget.categoryId,
createdAt: budget.createdAt,
currency: budget.currency,
id: budget.id,
month: this.formatBudgetMonth(budget.month),
remaining: budget.amount - spent,
spent,
updatedAt: budget.updatedAt
};
}
private async validateBudgetOwnership({
id,
userId
}: {
id: string;
userId: string;
}) {
const budget = await this.prismaService.budget.findFirst({
where: { id, userId }
});
if (!budget) {
throw new ForbiddenException();
}
}
private async validateCategoryOwnership({
categoryId,
userId
}: {
categoryId: string;
userId: string;
}) {
const category = await this.prismaService.expenseCategory.findFirst({
where: {
id: categoryId,
userId
}
});
if (!category) {
throw new ForbiddenException();
}
}
}

16
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<boolean> {
try {
return (await this.jwtGuard.canActivate(context)) as boolean;
} catch {
return (await this.apiKeyGuard.canActivate(context)) as boolean;
}
}
}

557
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 <api_key>`; the Angular app continues to use JWT bearer auth.
**Tech Stack:** Angular 21, Angular Material, NestJS, Prisma, PostgreSQL, Nx, Jest.
---
## File Structure
- Modify `prisma/schema.prisma`: add `Budget` and relation to `ExpenseCategory` and `User`.
- Create DTOs in `libs/common/src/lib/dtos/`: `create-budget.dto.ts`, `update-budget.dto.ts`; export from `index.ts`.
- Create response interfaces in `libs/common/src/lib/interfaces/responses/`: `budget-response.interface.ts`; export from `libs/common/src/lib/interfaces/index.ts`.
- Modify `libs/common/src/lib/permissions.ts`: add budget read/write permissions and grant them to `ADMIN` and `USER`.
- Create API files under `apps/api/src/app/budgets/`: `budgets.module.ts`, `budgets.controller.ts`, `budgets.service.ts`, `budgets.service.spec.ts`.
- Modify `apps/api/src/app/app.module.ts`: import `BudgetsModule`.
- Modify `libs/ui/src/lib/services/data.service.ts`: add budget client methods.
- Modify `libs/common/src/lib/routes/routes.ts`: add `internalRoutes.portfolio.subRoutes.budget`.
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.component.ts`: add Budget tab.
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts`: add lazy child route.
- Create Angular feature files under `apps/client/src/app/pages/portfolio/budget/`: `budget-page.routes.ts`, `budget-page.component.ts`, `budget-page.html`, `budget-page.scss`, `create-or-update-budget-dialog/*`.
## Task 1: Shared Budget Contracts
**Files:**
- Modify: `libs/common/src/lib/permissions.ts`
- Create: `libs/common/src/lib/dtos/create-budget.dto.ts`
- Create: `libs/common/src/lib/dtos/update-budget.dto.ts`
- Modify: `libs/common/src/lib/dtos/index.ts`
- Create: `libs/common/src/lib/interfaces/responses/budget-response.interface.ts`
- Modify: `libs/common/src/lib/interfaces/index.ts`
- [ ] **Step 1: Add permissions**
Add these permission constants:
```ts
createBudget: 'createBudget',
deleteBudget: 'deleteBudget',
readBudgets: 'readBudgets',
updateBudget: 'updateBudget',
```
Add all four permissions to `ADMIN` and `USER`. Do not add them to `DEMO`.
- [ ] **Step 2: Add DTOs**
Use these DTOs exactly:
```ts
// create-budget.dto.ts
export class CreateBudgetDto {
@IsNumber()
@Min(0)
amount: number;
@IsString()
categoryId: string;
@IsCurrencyCode()
currency: string;
@Matches(/^\d{4}-\d{2}$/)
month: string;
}
```
```ts
// update-budget.dto.ts
export class UpdateBudgetDto extends CreateBudgetDto {
@IsString()
id: string;
}
```
- [ ] **Step 3: Add response interfaces**
```ts
import { ExpenseCategoryResponse } from './expense-category-response.interface';
export interface BudgetResponse {
amount: number;
category: ExpenseCategoryResponse;
categoryId: string;
createdAt: Date;
currency: string;
id: string;
month: string;
remaining: number;
spent: number;
updatedAt: Date;
}
export interface BudgetsResponse {
budgets: BudgetResponse[];
totalBudgeted: number;
totalRemaining: number;
totalSpent: number;
}
```
- [ ] **Step 4: Run common tests**
Run:
```bash
npm run test:common
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add libs/common/src/lib
git commit -m "feat: add budget contracts"
```
## Task 2: Prisma Budget Schema
**Files:**
- Modify: `prisma/schema.prisma`
- [ ] **Step 1: Add Budget model**
Add:
```prisma
model Budget {
amount Float
category ExpenseCategory @relation(fields: [categoryId], onDelete: Cascade, references: [id])
categoryId String
createdAt DateTime @default(now())
currency String
id String @id @default(uuid())
month DateTime
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
@@unique([categoryId, month, userId])
@@index([categoryId])
@@index([month])
@@index([userId])
}
```
Add relation fields:
```prisma
model ExpenseCategory {
budgets Budget[]
}
model User {
budgets Budget[]
}
```
- [ ] **Step 2: Format and validate schema**
Run:
```bash
npm run database:format-schema
npm run database:validate-schema
```
Expected: PASS.
- [ ] **Step 3: Generate Prisma client**
Run:
```bash
npm run database:generate-typings
```
Expected: PASS and Prisma types include `Budget`.
- [ ] **Step 4: Commit**
```bash
git add prisma/schema.prisma
git commit -m "feat: add budget prisma model"
```
## Task 3: Budget API
**Files:**
- Create: `apps/api/src/app/budgets/budgets.service.ts`
- Create: `apps/api/src/app/budgets/budgets.service.spec.ts`
- Create: `apps/api/src/app/budgets/budgets.controller.ts`
- Create: `apps/api/src/app/budgets/budgets.module.ts`
- Modify: `apps/api/src/app/app.module.ts`
- [ ] **Step 1: Write failing service tests**
Create tests covering:
```ts
it('lists budgets for a month and includes spent and remaining amounts');
it('creates a budget for a category owned by the current user');
it('updates only a budget owned by the current user');
it('deletes only a budget owned by the current user');
it('rejects duplicate budgets for the same category and month');
```
Mock `PrismaService` methods:
```ts
budget.findMany;
budget.create;
budget.update;
budget.delete;
budget.findFirst;
expense.groupBy;
expenseCategory.findFirst;
```
- [ ] **Step 2: Run failing test**
Run:
```bash
nx test api --testFile=budgets.service.spec.ts
```
Expected: FAIL because `BudgetsService` does not exist.
- [ ] **Step 3: Implement month helpers**
Inside `BudgetsService`, implement:
```ts
private parseBudgetMonth(month: string): Date {
return new Date(`${month}-01T00:00:00.000Z`);
}
private formatBudgetMonth(month: Date): string {
return month.toISOString().slice(0, 7);
}
```
- [ ] **Step 4: Implement service methods**
Implement:
```ts
getBudgets({ month, userId }: { month: string; userId: string })
getBudget({ id, userId }: { id: string; userId: string })
createBudget({ data, userId }: { data: CreateBudgetDto; userId: string })
updateBudget({ data, id, userId }: { data: UpdateBudgetDto; id: string; userId: string })
deleteBudget({ id, userId }: { id: string; userId: string })
```
Rules:
- Always scope by `userId`.
- Verify the category belongs to the user before create/update.
- Store `month` as the first day of that month in UTC.
- Calculate `spent` from expenses where `date >= monthStart`, `date < nextMonthStart`, `categoryId` matches, and `userId` matches.
- Calculate `remaining = amount - spent`.
- Return summary totals in `BudgetsResponse`.
- [ ] **Step 5: Implement controller**
Add routes:
```http
GET /api/v1/budgets?month=2026-06
POST /api/v1/budgets
GET /api/v1/budgets/:id
PUT /api/v1/budgets/:id
DELETE /api/v1/budgets/:id
```
Use:
```ts
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
```
Apply permissions:
```ts
readBudgets;
createBudget;
updateBudget;
deleteBudget;
```
External integration request example:
```bash
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \
"https://example.com/api/v1/budgets?month=2026-06"
```
- [ ] **Step 6: Register module**
Import `BudgetsModule` in `apps/api/src/app/app.module.ts`.
- [ ] **Step 7: Run API tests**
Run:
```bash
nx test api --testFile=budgets.service.spec.ts
npm run test:api
```
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add apps/api/src/app/budgets apps/api/src/app/app.module.ts
git commit -m "feat: add budgets api"
```
## Task 4: Angular Data Service
**Files:**
- Modify: `libs/ui/src/lib/services/data.service.ts`
- [ ] **Step 1: Add imports**
Import `CreateBudgetDto`, `UpdateBudgetDto`, `BudgetResponse`, and `BudgetsResponse`.
- [ ] **Step 2: Add data methods**
```ts
public fetchBudgets({ month }: { month: string }) {
let params = new HttpParams();
params = params.append('month', month);
return this.http.get<BudgetsResponse>('/api/v1/budgets', { params });
}
public postBudget(aBudget: CreateBudgetDto) {
return this.http.post<BudgetResponse>('/api/v1/budgets', aBudget);
}
public putBudget(aBudget: UpdateBudgetDto) {
return this.http.put<BudgetResponse>(`/api/v1/budgets/${aBudget.id}`, aBudget);
}
public deleteBudget(aId: string) {
return this.http.delete<BudgetResponse>(`/api/v1/budgets/${aId}`);
}
```
- [ ] **Step 3: Run UI library lint**
Run:
```bash
nx run ui:lint
```
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add libs/ui/src/lib/services/data.service.ts
git commit -m "feat: add budget data service methods"
```
## Task 5: Budget Portfolio Tab UI
**Files:**
- Modify: `libs/common/src/lib/routes/routes.ts`
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.component.ts`
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts`
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.routes.ts`
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.component.ts`
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.html`
- Create: `apps/client/src/app/pages/portfolio/budget/budget-page.scss`
- [ ] **Step 1: Add route metadata**
Add:
```ts
budget: {
path: 'budget',
routerLink: ['/portfolio', 'budget'],
title: $localize`Budget`
}
```
- [ ] **Step 2: Add tab**
In `PortfolioPageComponent`, import an icon such as `walletOutline` from `ionicons/icons`, register it with `addIcons`, and add:
```ts
{
iconName: 'wallet-outline',
label: internalRoutes.portfolio.subRoutes.budget.title,
routerLink: internalRoutes.portfolio.subRoutes.budget.routerLink
}
```
- [ ] **Step 3: Add lazy route**
Add child route:
```ts
{
path: internalRoutes.portfolio.subRoutes.budget.path,
loadChildren: () =>
import('./budget/budget-page.routes').then((m) => m.routes)
}
```
- [ ] **Step 4: Implement page**
The page must:
- default to the current month formatted as `YYYY-MM`
- fetch categories through `fetchExpenseCategories()`
- fetch budgets through `fetchBudgets({ month })`
- show total budgeted, total spent, and total remaining
- show a Material table with category, budget amount, spent, remaining, and actions
- open a create/edit dialog for budgets
- [ ] **Step 5: Run client checks**
Run:
```bash
nx run client:lint
nx run client:build
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add libs/common/src/lib/routes/routes.ts apps/client/src/app/pages/portfolio
git commit -m "feat: add budget portfolio tab"
```
## Task 6: Budget Dialog
**Files:**
- Create: `apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts`
- Create: `apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html`
- Create: `apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss`
- [ ] **Step 1: Implement dialog form**
Use Reactive Forms with fields:
```ts
(amount, categoryId, currency, month);
```
Required fields:
```ts
(amount, categoryId, currency, month);
```
- [ ] **Step 2: Validate unique category per month in UI**
When creating a budget, hide or disable categories that already have a budget for the selected month.
- [ ] **Step 3: Wire dialog to page**
After create/update/delete, refresh budgets and call `markForCheck()`.
- [ ] **Step 4: Run client checks**
Run:
```bash
nx run client:lint
nx run client:build
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add apps/client/src/app/pages/portfolio/budget
git commit -m "feat: add budget management dialog"
```
## Task 7: Final Verification
- [ ] **Step 1: Run schema checks**
```bash
npm run database:validate-schema
```
Expected: PASS.
- [ ] **Step 2: Run focused tests**
```bash
npm run test:common
npm run test:api
```
Expected: PASS.
- [ ] **Step 3: Run production build**
```bash
npm run build:production
```
Expected: PASS.
- [ ] **Step 4: Manual API verification**
Create an API key from the existing API page, then run:
```bash
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \
"http://localhost:3333/api/v1/budgets?month=2026-06"
```
Expected: `200 OK` and a `BudgetsResponse`.
- [ ] **Step 5: Manual UI verification**
Start the app:
```bash
npm run start:server
npm run start:client
```
Open `https://localhost:4200/en/portfolio/budget`. Confirm the Budget tab appears, budgets can be created for expense categories, totals update after expenses are added, and duplicate category/month budgets are prevented.
## Self-Review
- Spec coverage: first-class budget records, UI tab, CRUD API, API-key integration, JWT UI access, user isolation, month summaries, and dependency on expenses are covered.
- Placeholder scan: no placeholder markers or unassigned implementation decisions remain.
- Type consistency: DTO names, route names, permission names, response names, and dependency on `ExpenseCategory` match across tasks.

792
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 <api_key>`; the Angular app continues to use JWT bearer auth.
**Tech Stack:** Angular 21, Angular Material, NestJS, Prisma, PostgreSQL, Nx, Jest.
---
## File Structure
- Modify `prisma/schema.prisma`: add `ExpenseCategory`, `Expense`, user/account/tag relations.
- Modify `libs/common/src/lib/permissions.ts`: add expense permissions and grant them to `ADMIN` and `USER`.
- Create DTOs in `libs/common/src/lib/dtos/`: `create-expense-category.dto.ts`, `update-expense-category.dto.ts`, `create-expense.dto.ts`, `update-expense.dto.ts`; export from `index.ts`.
- Create response interfaces in `libs/common/src/lib/interfaces/responses/`: `expense-category-response.interface.ts`, `expenses-response.interface.ts`; export from `libs/common/src/lib/interfaces/index.ts`.
- Create API module files under `apps/api/src/app/expenses/`: `expenses.module.ts`, `expenses.controller.ts`, `expenses.service.ts`, `expenses.service.spec.ts`, `expense-categories.controller.ts`, `expense-categories.service.ts`, `expense-categories.service.spec.ts`.
- Create `apps/api/src/guards/jwt-or-api-key-auth.guard.ts` to support JWT and API-key integrations on the new endpoints.
- Modify `apps/api/src/app/app.module.ts`: import `ExpensesModule`.
- Modify `libs/ui/src/lib/services/data.service.ts`: add expense category and expense client methods.
- Modify `libs/common/src/lib/routes/routes.ts`: add `internalRoutes.portfolio.subRoutes.expenses`.
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.component.ts`: add Expenses tab.
- Modify `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts`: add lazy child route.
- Create Angular feature files under `apps/client/src/app/pages/portfolio/expenses/`: `expenses-page.routes.ts`, `expenses-page.component.ts`, `expenses-page.html`, `expenses-page.scss`, `create-or-update-expense-dialog/*`, `create-or-update-expense-category-dialog/*`.
## Task 1: Shared Expense Contracts
**Files:**
- Modify: `libs/common/src/lib/permissions.ts`
- Create: `libs/common/src/lib/dtos/create-expense-category.dto.ts`
- Create: `libs/common/src/lib/dtos/update-expense-category.dto.ts`
- Create: `libs/common/src/lib/dtos/create-expense.dto.ts`
- Create: `libs/common/src/lib/dtos/update-expense.dto.ts`
- Modify: `libs/common/src/lib/dtos/index.ts`
- Create: `libs/common/src/lib/interfaces/responses/expense-category-response.interface.ts`
- Create: `libs/common/src/lib/interfaces/responses/expenses-response.interface.ts`
- Modify: `libs/common/src/lib/interfaces/index.ts`
- [ ] **Step 1: Add permissions**
Add these permission constants to `permissions`:
```ts
createExpense: 'createExpense',
createExpenseCategory: 'createExpenseCategory',
deleteExpense: 'deleteExpense',
deleteExpenseCategory: 'deleteExpenseCategory',
readExpenses: 'readExpenses',
readExpenseCategories: 'readExpenseCategories',
updateExpense: 'updateExpense',
updateExpenseCategory: 'updateExpenseCategory',
```
Add all eight permissions to `ADMIN` and `USER`. Do not add them to `DEMO`.
- [ ] **Step 2: Add DTO files**
Use these DTO shapes exactly:
```ts
// create-expense-category.dto.ts
export class CreateExpenseCategoryDto {
@IsString()
name: string;
@IsHexColor()
@IsOptional()
color?: string;
}
```
```ts
// update-expense-category.dto.ts
export class UpdateExpenseCategoryDto {
@IsString()
id: string;
@IsString()
name: string;
@IsHexColor()
@IsOptional()
color?: string;
}
```
```ts
// create-expense.dto.ts
export class CreateExpenseDto {
@IsString()
@IsOptional()
accountId?: string;
@IsNumber()
@Min(0)
amount: number;
@IsString()
@IsOptional()
categoryId?: string;
@IsString()
@IsOptional()
comment?: string;
@IsCurrencyCode()
currency: string;
@IsISO8601()
date: string;
@IsString()
@IsOptional()
merchant?: string;
@IsArray()
@IsOptional()
tagIds?: string[];
}
```
```ts
// update-expense.dto.ts
export class UpdateExpenseDto extends CreateExpenseDto {
@IsString()
id: string;
}
```
- [ ] **Step 3: Add response interfaces**
Use these response contracts:
```ts
export interface ExpenseCategoryResponse {
color?: string;
createdAt: Date;
id: string;
name: string;
updatedAt: Date;
}
```
```ts
import { Account, Tag } from '@prisma/client';
export interface ExpenseResponse {
account?: Account;
accountId?: string;
amount: number;
categoryId?: string;
comment?: string;
createdAt: Date;
currency: string;
date: Date;
id: string;
merchant?: string;
tags: Tag[];
updatedAt: Date;
}
export interface ExpensesResponse {
count: number;
expenses: ExpenseResponse[];
}
```
- [ ] **Step 4: Run common tests**
Run:
```bash
npm run test:common
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add libs/common/src/lib
git commit -m "feat: add expense contracts"
```
## Task 2: Prisma Expense Schema
**Files:**
- Modify: `prisma/schema.prisma`
- [ ] **Step 1: Add Prisma models**
Add these models and relations:
```prisma
model ExpenseCategory {
color String?
createdAt DateTime @default(now())
expenses Expense[]
id String @id @default(uuid())
name String
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
@@unique([name, userId])
@@index([name])
@@index([userId])
}
model Expense {
account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountId String?
accountUserId String?
amount Float
category ExpenseCategory? @relation(fields: [categoryId], onDelete: SetNull, references: [id])
categoryId String?
comment String?
createdAt DateTime @default(now())
currency String
date DateTime
id String @id @default(uuid())
merchant String?
tags Tag[]
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
@@index([accountId])
@@index([categoryId])
@@index([currency])
@@index([date])
@@index([merchant])
@@index([userId])
}
```
Add relation fields:
```prisma
model Account {
expenses Expense[]
}
model Tag {
expenses Expense[]
}
model User {
expenseCategories ExpenseCategory[]
expenses Expense[]
}
```
- [ ] **Step 2: Format and validate schema**
Run:
```bash
npm run database:format-schema
npm run database:validate-schema
```
Expected: both commands PASS.
- [ ] **Step 3: Generate Prisma client**
Run:
```bash
npm run database:generate-typings
```
Expected: PASS and Prisma types include `Expense` and `ExpenseCategory`.
- [ ] **Step 4: Commit**
```bash
git add prisma/schema.prisma
git commit -m "feat: add expense prisma models"
```
## Task 3: JWT Or API-Key Guard
**Files:**
- Create: `apps/api/src/guards/jwt-or-api-key-auth.guard.ts`
- [ ] **Step 1: Create the guard**
```ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtOrApiKeyAuthGuard implements CanActivate {
private readonly jwtGuard = new (AuthGuard('jwt'))();
private readonly apiKeyGuard = new (AuthGuard('api-key'))();
public async canActivate(context: ExecutionContext): Promise<boolean> {
try {
return (await this.jwtGuard.canActivate(context)) as boolean;
} catch {
return (await this.apiKeyGuard.canActivate(context)) as boolean;
}
}
}
```
- [ ] **Step 2: Build API**
Run:
```bash
nx run api:build
```
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/api/src/guards/jwt-or-api-key-auth.guard.ts
git commit -m "feat: support jwt or api key auth"
```
## Task 4: Expense Category API
**Files:**
- Create: `apps/api/src/app/expenses/expense-categories.service.ts`
- Create: `apps/api/src/app/expenses/expense-categories.service.spec.ts`
- Create: `apps/api/src/app/expenses/expense-categories.controller.ts`
- [ ] **Step 1: Write failing service tests**
Create tests covering:
```ts
it('returns only categories for the current user');
it('creates a category for the current user');
it('updates only a category owned by the current user');
it('blocks deletion when expenses reference the category');
it('deletes an unused category owned by the current user');
```
Mock `PrismaService` with `expenseCategory.findMany`, `expenseCategory.create`, `expenseCategory.update`, `expenseCategory.delete`, and `expense.count`.
- [ ] **Step 2: Run failing test**
Run:
```bash
nx test api --testFile=expense-categories.service.spec.ts
```
Expected: FAIL because the service does not exist yet.
- [ ] **Step 3: Implement service**
Implement methods:
```ts
getCategories({ userId }: { userId: string })
createCategory({ data, userId }: { data: CreateExpenseCategoryDto; userId: string })
updateCategory({ data, id, userId }: { data: UpdateExpenseCategoryDto; id: string; userId: string })
deleteCategory({ id, userId }: { id: string; userId: string })
```
Deletion must check `expense.count({ where: { categoryId: id, userId } })`; if count is greater than `0`, throw `HttpException(CONFLICT, 409)`.
- [ ] **Step 4: Implement controller**
Add routes:
```http
GET /api/v1/expense-categories
POST /api/v1/expense-categories
PUT /api/v1/expense-categories/:id
DELETE /api/v1/expense-categories/:id
```
Use:
```ts
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
```
Apply permissions:
```ts
readExpenseCategories;
createExpenseCategory;
updateExpenseCategory;
deleteExpenseCategory;
```
- [ ] **Step 5: Run service tests**
Run:
```bash
nx test api --testFile=expense-categories.service.spec.ts
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/app/expenses apps/api/src/guards
git commit -m "feat: add expense category api"
```
## Task 5: Expense API
**Files:**
- Create: `apps/api/src/app/expenses/expenses.service.ts`
- Create: `apps/api/src/app/expenses/expenses.service.spec.ts`
- Create: `apps/api/src/app/expenses/expenses.controller.ts`
- Create: `apps/api/src/app/expenses/expenses.module.ts`
- Modify: `apps/api/src/app/app.module.ts`
- [ ] **Step 1: Write failing service tests**
Create tests covering:
```ts
it('lists paginated expenses for the current user');
it('filters expenses by date range and category');
it('creates an expense with optional account, category, and tags');
it('updates only an expense owned by the current user');
it('deletes only an expense owned by the current user');
```
- [ ] **Step 2: Run failing test**
Run:
```bash
nx test api --testFile=expenses.service.spec.ts
```
Expected: FAIL because `ExpensesService` does not exist.
- [ ] **Step 3: Implement service**
Implement methods:
```ts
getExpenses({
categoryId,
from,
skip,
sortColumn = 'date',
sortDirection = 'desc',
take,
to,
userId
});
getExpense({ id, userId });
createExpense({ data, userId });
updateExpense({ data, id, userId });
deleteExpense({ id, userId });
```
Always scope Prisma `where` clauses by `userId`. Include `account`, `category`, and `tags` in returned expenses.
- [ ] **Step 4: Implement controller**
Add routes:
```http
GET /api/v1/expenses?from=2026-06-01&to=2026-06-30&categoryId=...&skip=0&take=50
POST /api/v1/expenses
GET /api/v1/expenses/:id
PUT /api/v1/expenses/:id
DELETE /api/v1/expenses/:id
```
Use `JwtOrApiKeyAuthGuard` and `HasPermissionGuard`.
Apply permissions:
```ts
readExpenses;
createExpense;
updateExpense;
deleteExpense;
```
External integration request example:
```bash
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \
"https://example.com/api/v1/expenses?from=2026-06-01&to=2026-06-30"
```
- [ ] **Step 5: Register module**
Import `ExpensesModule` in `apps/api/src/app/app.module.ts`.
- [ ] **Step 6: Run tests**
Run:
```bash
nx test api --testFile=expenses.service.spec.ts
npm run test:api
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add apps/api/src/app/expenses apps/api/src/app/app.module.ts
git commit -m "feat: add expenses api"
```
## Task 6: Angular Data Service
**Files:**
- Modify: `libs/ui/src/lib/services/data.service.ts`
- [ ] **Step 1: Add imports**
Import the new DTOs and response interfaces from `@ghostfolio/common`.
- [ ] **Step 2: Add category methods**
```ts
public fetchExpenseCategories() {
return this.http.get<ExpenseCategoryResponse[]>('/api/v1/expense-categories');
}
public postExpenseCategory(aCategory: CreateExpenseCategoryDto) {
return this.http.post<ExpenseCategoryResponse>('/api/v1/expense-categories', aCategory);
}
public putExpenseCategory(aCategory: UpdateExpenseCategoryDto) {
return this.http.put<ExpenseCategoryResponse>(
`/api/v1/expense-categories/${aCategory.id}`,
aCategory
);
}
public deleteExpenseCategory(aId: string) {
return this.http.delete<ExpenseCategoryResponse>(`/api/v1/expense-categories/${aId}`);
}
```
- [ ] **Step 3: Add expense methods**
```ts
public fetchExpenses({ categoryId, from, skip, sortColumn, sortDirection, take, to }) {
let params = new HttpParams();
if (categoryId) params = params.append('categoryId', categoryId);
if (from) params = params.append('from', format(from, DATE_FORMAT, { in: utc }));
if (to) params = params.append('to', format(to, DATE_FORMAT, { in: utc }));
if (skip) params = params.append('skip', skip);
if (sortColumn) params = params.append('sortColumn', sortColumn);
if (sortDirection) params = params.append('sortDirection', sortDirection);
if (take) params = params.append('take', take);
return this.http.get<ExpensesResponse>('/api/v1/expenses', { params });
}
public postExpense(aExpense: CreateExpenseDto) {
return this.http.post<ExpenseResponse>('/api/v1/expenses', aExpense);
}
public putExpense(aExpense: UpdateExpenseDto) {
return this.http.put<ExpenseResponse>(`/api/v1/expenses/${aExpense.id}`, aExpense);
}
public deleteExpense(aId: string) {
return this.http.delete<ExpenseResponse>(`/api/v1/expenses/${aId}`);
}
```
- [ ] **Step 4: Build UI library**
Run:
```bash
nx run ui:lint
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add libs/ui/src/lib/services/data.service.ts
git commit -m "feat: add expense data service methods"
```
## Task 7: Expenses Portfolio Tab UI
**Files:**
- Modify: `libs/common/src/lib/routes/routes.ts`
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.component.ts`
- Modify: `apps/client/src/app/pages/portfolio/portfolio-page.routes.ts`
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.routes.ts`
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.component.ts`
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.html`
- Create: `apps/client/src/app/pages/portfolio/expenses/expenses-page.scss`
- [ ] **Step 1: Add route metadata**
Add:
```ts
expenses: {
path: 'expenses',
routerLink: ['/portfolio', 'expenses'],
title: $localize`Expenses`
}
```
- [ ] **Step 2: Add tab**
In `PortfolioPageComponent`, import an icon such as `receiptOutline` from `ionicons/icons`, register it with `addIcons`, and add:
```ts
{
iconName: 'receipt-outline',
label: internalRoutes.portfolio.subRoutes.expenses.title,
routerLink: internalRoutes.portfolio.subRoutes.expenses.routerLink
}
```
- [ ] **Step 3: Add lazy route**
Add child route:
```ts
{
path: internalRoutes.portfolio.subRoutes.expenses.path,
loadChildren: () =>
import('./expenses/expenses-page.routes').then((m) => m.routes)
}
```
- [ ] **Step 4: Implement page**
The page must:
- fetch categories and expenses on init
- show a Material table with date, merchant, category, amount, currency, account, comment, actions
- support pagination and date/category filters
- open create/edit/delete flows through dialogs
- [ ] **Step 5: Run client lint/build**
Run:
```bash
nx run client:lint
nx run client:build
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add libs/common/src/lib/routes/routes.ts apps/client/src/app/pages/portfolio
git commit -m "feat: add expenses portfolio tab"
```
## Task 8: Expense Dialogs
**Files:**
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-dialog/create-or-update-expense-dialog.component.ts`
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-dialog/create-or-update-expense-dialog.html`
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-dialog/create-or-update-expense-dialog.scss`
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-category-dialog/create-or-update-expense-category-dialog.component.ts`
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-category-dialog/create-or-update-expense-category-dialog.html`
- Create: `apps/client/src/app/pages/portfolio/expenses/create-or-update-expense-category-dialog/create-or-update-expense-category-dialog.scss`
- [ ] **Step 1: Implement expense dialog fields**
Use Reactive Forms with fields:
```ts
(accountId, amount, categoryId, comment, currency, date, merchant, tagIds);
```
Required fields:
```ts
(amount, currency, date);
```
- [ ] **Step 2: Implement category dialog fields**
Use Reactive Forms with fields:
```ts
(name, color);
```
Required field:
```ts
name;
```
- [ ] **Step 3: Wire dialogs to page**
After each create/update/delete operation, refresh category and expense data and call `markForCheck()`.
- [ ] **Step 4: Run client checks**
Run:
```bash
nx run client:lint
nx run client:build
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add apps/client/src/app/pages/portfolio/expenses
git commit -m "feat: add expense management dialogs"
```
## Task 9: Final Verification
- [ ] **Step 1: Run schema checks**
```bash
npm run database:validate-schema
```
Expected: PASS.
- [ ] **Step 2: Run focused tests**
```bash
npm run test:common
npm run test:api
```
Expected: PASS.
- [ ] **Step 3: Run production build**
```bash
npm run build:production
```
Expected: PASS.
- [ ] **Step 4: Manual API verification**
Create an API key from the existing API page, then run:
```bash
curl -H "Authorization: Api-Key $GHOSTFOLIO_API_KEY" \
http://localhost:3333/api/v1/expense-categories
```
Expected: `200 OK` and an array response.
- [ ] **Step 5: Manual UI verification**
Start the app:
```bash
npm run start:server
npm run start:client
```
Open `https://localhost:4200/en/portfolio/expenses`. Confirm the Expenses tab appears, categories can be created, expenses can be created, and the table refreshes.
## Self-Review
- Spec coverage: first-class expense records, UI tab, CRUD API, API-key integration, JWT UI access, user isolation, categories, tags, and accounts are covered.
- Placeholder scan: no placeholder markers or unassigned implementation decisions remain.
- Type consistency: DTO names, route names, permission names, and response names match across tasks.

18
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;
}

10
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;
}

42
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[];
}

12
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 { CreateAccountDto } from './create-account.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
import { CreateAssetProfileDto } from './create-asset-profile.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 { CreateOrderDto } from './create-order.dto';
import { CreatePlatformDto } from './create-platform.dto'; import { CreatePlatformDto } from './create-platform.dto';
import { CreateTagDto } from './create-tag.dto'; import { CreateTagDto } from './create-tag.dto';
@ -15,7 +18,10 @@ import { UpdateAccessDto } from './update-access.dto';
import { UpdateAccountDto } from './update-account.dto'; import { UpdateAccountDto } from './update-account.dto';
import { UpdateAssetProfileDataDto } from './update-asset-profile-data.dto'; import { UpdateAssetProfileDataDto } from './update-asset-profile-data.dto';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBudgetDto } from './update-budget.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.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 { UpdateMarketDataDto } from './update-market-data.dto';
import { UpdateOrderDto } from './update-order.dto'; import { UpdateOrderDto } from './update-order.dto';
import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto'; import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto';
@ -32,6 +38,9 @@ export {
CreateAccountWithBalancesDto, CreateAccountWithBalancesDto,
CreateAssetProfileDto, CreateAssetProfileDto,
CreateAssetProfileWithMarketDataDto, CreateAssetProfileWithMarketDataDto,
CreateBudgetDto,
CreateExpenseCategoryDto,
CreateExpenseDto,
CreateOrderDto, CreateOrderDto,
CreatePlatformDto, CreatePlatformDto,
CreateTagDto, CreateTagDto,
@ -42,7 +51,10 @@ export {
UpdateAccountDto, UpdateAccountDto,
UpdateAssetProfileDataDto, UpdateAssetProfileDataDto,
UpdateAssetProfileDto, UpdateAssetProfileDto,
UpdateBudgetDto,
UpdateBulkMarketDataDto, UpdateBulkMarketDataDto,
UpdateExpenseCategoryDto,
UpdateExpenseDto,
UpdateMarketDataDto, UpdateMarketDataDto,
UpdateOrderDto, UpdateOrderDto,
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,

8
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;
}

13
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;
}

8
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;
}

14
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 { AssetResponse } from './responses/asset-response.interface';
import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-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 { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface';
import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface'; import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface';
import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-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'; } from './responses/data-provider-response.interface';
import type { DividendsResponse } from './responses/dividends-response.interface'; import type { DividendsResponse } from './responses/dividends-response.interface';
import type { ResponseError } from './responses/errors.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 { ExportResponse } from './responses/export-response.interface';
import type { HistoricalResponse } from './responses/historical-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface';
import type { ImportResponse } from './responses/import-response.interface'; import type { ImportResponse } from './responses/import-response.interface';
@ -131,6 +140,8 @@ export {
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
BudgetResponse,
BudgetsResponse,
Coupon, Coupon,
CreateStripeCheckoutSessionResponse, CreateStripeCheckoutSessionResponse,
DataEnhancerHealthResponse, DataEnhancerHealthResponse,
@ -142,6 +153,9 @@ export {
DataProviderResponse, DataProviderResponse,
DividendsResponse, DividendsResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
ExpenseCategoryResponse,
ExpenseResponse,
ExpensesResponse,
ExportResponse, ExportResponse,
Filter, Filter,
FilterGroup, FilterGroup,

21
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;
}

7
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;
}

21
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[];
}

31
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);
}
});
});

36
libs/common/src/lib/permissions.ts

@ -12,6 +12,9 @@ export const permissions = {
createAccountBalance: 'createAccountBalance', createAccountBalance: 'createAccountBalance',
createActivity: 'createActivity', createActivity: 'createActivity',
createApiKey: 'createApiKey', createApiKey: 'createApiKey',
createBudget: 'createBudget',
createExpense: 'createExpense',
createExpenseCategory: 'createExpenseCategory',
createMarketData: 'createMarketData', createMarketData: 'createMarketData',
createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile', createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile',
createOwnTag: 'createOwnTag', createOwnTag: 'createOwnTag',
@ -24,6 +27,9 @@ export const permissions = {
deleteAccountBalance: 'deleteAccountBalance', deleteAccountBalance: 'deleteAccountBalance',
deleteActivity: 'deleteActivity', deleteActivity: 'deleteActivity',
deleteAuthDevice: 'deleteAuthDevice', deleteAuthDevice: 'deleteAuthDevice',
deleteBudget: 'deleteBudget',
deleteExpense: 'deleteExpense',
deleteExpenseCategory: 'deleteExpenseCategory',
deleteOwnUser: 'deleteOwnUser', deleteOwnUser: 'deleteOwnUser',
deletePlatform: 'deletePlatform', deletePlatform: 'deletePlatform',
deleteTag: 'deleteTag', deleteTag: 'deleteTag',
@ -42,6 +48,9 @@ export const permissions = {
enableSystemMessage: 'enableSystemMessage', enableSystemMessage: 'enableSystemMessage',
impersonateAllUsers: 'impersonateAllUsers', impersonateAllUsers: 'impersonateAllUsers',
readAiPrompt: 'readAiPrompt', readAiPrompt: 'readAiPrompt',
readBudgets: 'readBudgets',
readExpenses: 'readExpenses',
readExpenseCategories: 'readExpenseCategories',
readMarketData: 'readMarketData', readMarketData: 'readMarketData',
readMarketDataOfMarkets: 'readMarketDataOfMarkets', readMarketDataOfMarkets: 'readMarketDataOfMarkets',
readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile',
@ -56,6 +65,9 @@ export const permissions = {
updateAccess: 'updateAccess', updateAccess: 'updateAccess',
updateActivity: 'updateActivity', updateActivity: 'updateActivity',
updateAuthDevice: 'updateAuthDevice', updateAuthDevice: 'updateAuthDevice',
updateBudget: 'updateBudget',
updateExpense: 'updateExpense',
updateExpenseCategory: 'updateExpenseCategory',
updateMarketData: 'updateMarketData', updateMarketData: 'updateMarketData',
updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile',
updateOwnAccessToken: 'updateOwnAccessToken', updateOwnAccessToken: 'updateOwnAccessToken',
@ -76,6 +88,9 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount, permissions.createAccount,
permissions.createAccountBalance, permissions.createAccountBalance,
permissions.createActivity, permissions.createActivity,
permissions.createBudget,
permissions.createExpense,
permissions.createExpenseCategory,
permissions.createWatchlistItem, permissions.createWatchlistItem,
permissions.deleteAccountBalance, permissions.deleteAccountBalance,
permissions.deleteWatchlistItem, permissions.deleteWatchlistItem,
@ -88,10 +103,16 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAccount, permissions.deleteAccount,
permissions.deleteActivity, permissions.deleteActivity,
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteBudget,
permissions.deleteExpense,
permissions.deleteExpenseCategory,
permissions.deletePlatform, permissions.deletePlatform,
permissions.deleteTag, permissions.deleteTag,
permissions.deleteUser, permissions.deleteUser,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readBudgets,
permissions.readExpenses,
permissions.readExpenseCategories,
permissions.readMarketData, permissions.readMarketData,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms, permissions.readPlatforms,
@ -102,6 +123,9 @@ export function getPermissions(aRole: Role): string[] {
permissions.updateAccess, permissions.updateAccess,
permissions.updateActivity, permissions.updateActivity,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateBudget,
permissions.updateExpense,
permissions.updateExpenseCategory,
permissions.updateMarketData, permissions.updateMarketData,
permissions.updateMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile,
permissions.updatePlatform, permissions.updatePlatform,
@ -127,6 +151,9 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount, permissions.createAccount,
permissions.createAccountBalance, permissions.createAccountBalance,
permissions.createActivity, permissions.createActivity,
permissions.createBudget,
permissions.createExpense,
permissions.createExpenseCategory,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag, permissions.createOwnTag,
permissions.createWatchlistItem, permissions.createWatchlistItem,
@ -135,8 +162,14 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAccountBalance, permissions.deleteAccountBalance,
permissions.deleteActivity, permissions.deleteActivity,
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteBudget,
permissions.deleteExpense,
permissions.deleteExpenseCategory,
permissions.deleteWatchlistItem, permissions.deleteWatchlistItem,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readBudgets,
permissions.readExpenses,
permissions.readExpenseCategories,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms, permissions.readPlatforms,
permissions.readWatchlist, permissions.readWatchlist,
@ -144,6 +177,9 @@ export function getPermissions(aRole: Role): string[] {
permissions.updateAccess, permissions.updateAccess,
permissions.updateActivity, permissions.updateActivity,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateBudget,
permissions.updateExpense,
permissions.updateExpenseCategory,
permissions.updateMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile,
permissions.updateUserSettings, permissions.updateUserSettings,
permissions.updateViewMode permissions.updateViewMode

65
prisma/schema.prisma

@ -32,6 +32,7 @@ model Account {
comment String? comment String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
currency String? currency String?
expenses Expense[]
id String @default(uuid()) id String @default(uuid())
isExcluded Boolean @default(false) isExcluded Boolean @default(false)
name String? name String?
@ -115,6 +116,66 @@ model AuthDevice {
@@index([userId]) @@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 { model MarketData {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
dataSource DataSource dataSource DataSource
@ -252,6 +313,7 @@ model Subscription {
model Tag { model Tag {
activities Order[] activities Order[]
expenses Expense[]
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) user User? @relation(fields: [userId], onDelete: Cascade, references: [id])
@ -271,7 +333,10 @@ model User {
apiKeys ApiKey[] apiKeys ApiKey[]
authChallenge String? authChallenge String?
authDevices AuthDevice[] authDevices AuthDevice[]
budgets Budget[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expenseCategories ExpenseCategory[]
expenses Expense[]
id String @id @default(uuid()) id String @id @default(uuid())
provider Provider @default(ANONYMOUS) provider Provider @default(ANONYMOUS)
role Role @default(USER) role Role @default(USER)

Loading…
Cancel
Save