Browse Source

Add budget categories and improve budget UI

pull/7140/head
Yip Shing him 3 weeks ago
parent
commit
185fe15804
  1. 64
      README.md
  2. 151
      apps/api/src/app/budgets/budgets.service.spec.ts
  3. 116
      apps/api/src/app/budgets/budgets.service.ts
  4. 55
      apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts
  5. 62
      apps/client/src/app/pages/portfolio/budget/budget-page.component.ts
  6. 187
      apps/client/src/app/pages/portfolio/budget/budget-page.html
  7. 23
      apps/client/src/app/pages/portfolio/budget/budget-page.scss
  8. 60
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts
  9. 49
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts
  10. 32
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html
  11. 100
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts
  12. 40
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts
  13. 72
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html
  14. 137
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss
  15. 547
      docs/superpowers/plans/2026-06-24-budget-ui-repair.md
  16. 27
      libs/common/src/lib/dtos/create-budget.dto.ts
  17. 5
      libs/common/src/lib/dtos/index.ts
  18. 4
      libs/common/src/lib/interfaces/index.ts
  19. 18
      libs/common/src/lib/interfaces/responses/budget-response.interface.ts
  20. 9
      libs/ui/src/lib/services/data.service.spec.ts
  21. 499
      package-lock.json
  22. 37
      prisma/schema.prisma

64
README.md

@ -270,6 +270,70 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
}
```
### Budgets (experimental)
#### Prerequisites
[Bearer Token](#authorization-bearer-token) or `Authorization: Api-Key <INSERT_API_KEY>` for authorization
#### List Budgets
`GET http://localhost:3333/api/v1/budgets?month=2026-06`
#### Create Budget
`POST http://localhost:3333/api/v1/budgets`
#### Body
```
{
"accountId": "clx...",
"amount": 500,
"categoryId": "clx...",
"currency": "USD",
"month": "2026-06",
"name": "Groceries",
"type": "EXPENSE"
}
```
| Field | Type | Description |
| ------------ | ------------------- | ------------------------------------------------------------------ |
| `accountId` | `string` (optional) | Id of the account |
| `amount` | `number` | Budget amount |
| `categoryId` | `string` | Id of the expense category |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `month` | `string` | Month in the format `YYYY-MM` |
| `name` | `string` | Name of the budget line |
| `type` | `string` | `EXPENSE` \| `CASH_SAVINGS` \| `INVESTMENT_SAVINGS` |
#### Additional Budget Endpoints
| Method | Endpoint | Description |
| -------- | ------------------------------------------------------- | ------------------------ |
| `GET` | `/api/v1/budgets/:id` | Get one budget |
| `PUT` | `/api/v1/budgets/:id` | Update one budget |
| `DELETE` | `/api/v1/budgets/:id` | Delete one budget |
| `GET` | `/api/v1/budgets/categories` | List expense categories |
| `POST` | `/api/v1/budgets/categories` | Create expense category |
| `PUT` | `/api/v1/budgets/categories/:id` | Update expense category |
| `DELETE` | `/api/v1/budgets/categories/:id` | Delete expense category |
#### Create Expense Category Body
```
{
"color": "#ff0000",
"name": "Food"
}
```
| Field | Type | Description |
| ------- | ------------------- | ---------------------------- |
| `color` | `string` (optional) | Hex color code |
| `name` | `string` | Name of the expense category |
### Portfolio (experimental)
#### Prerequisites

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

@ -6,6 +6,7 @@ import { BudgetsService } from './budgets.service';
describe('BudgetsService', () => {
const userId = 'user-1';
const accountId = 'account-1';
const categoryId = 'category-1';
const budgetId = 'budget-1';
const createdAt = new Date('2026-06-23T00:00:00.000Z');
@ -13,6 +14,9 @@ describe('BudgetsService', () => {
let budgetsService: BudgetsService;
let prismaService: {
account: {
findFirst: jest.Mock;
};
budget: {
create: jest.Mock;
delete: jest.Mock;
@ -34,6 +38,9 @@ describe('BudgetsService', () => {
beforeEach(() => {
prismaService = {
account: {
findFirst: jest.fn()
},
budget: {
create: jest.fn(),
delete: jest.fn(),
@ -61,6 +68,16 @@ describe('BudgetsService', () => {
it('lists budgets for a month and includes spent and remaining amounts', async () => {
prismaService.budget.findMany.mockResolvedValue([
{
account: {
balance: 0,
createdAt,
id: accountId,
isExcluded: false,
name: 'Checking',
updatedAt,
userId
},
accountId,
amount: 500,
category: {
color: '#0055aa',
@ -74,6 +91,29 @@ describe('BudgetsService', () => {
currency: 'USD',
id: budgetId,
month: new Date('2026-06-01T00:00:00.000Z'),
name: 'Food shop',
type: 'EXPENSE',
updatedAt,
userId
},
{
account: null,
accountId: null,
amount: 200,
category: {
color: '#0055aa',
createdAt,
id: categoryId,
name: 'Groceries',
updatedAt
},
categoryId,
createdAt,
currency: 'USD',
id: 'budget-2',
month: new Date('2026-06-01T00:00:00.000Z'),
name: 'Emergency fund',
type: 'CASH_SAVINGS',
updatedAt,
userId
}
@ -91,8 +131,8 @@ describe('BudgetsService', () => {
});
expect(prismaService.budget.findMany).toHaveBeenCalledWith({
include: { category: true },
orderBy: { category: { name: 'asc' } },
include: { account: true, category: true },
orderBy: { name: 'asc' },
where: {
month: new Date('2026-06-01T00:00:00.000Z'),
userId
@ -113,6 +153,16 @@ describe('BudgetsService', () => {
expect(response).toEqual({
budgets: [
{
account: {
balance: 0,
createdAt,
id: accountId,
isExcluded: false,
name: 'Checking',
updatedAt,
userId
},
accountId,
amount: 500,
category: {
color: '#0055aa',
@ -126,13 +176,37 @@ describe('BudgetsService', () => {
currency: 'USD',
id: budgetId,
month: '2026-06',
name: 'Food shop',
remaining: 375,
spent: 125,
type: 'EXPENSE',
updatedAt
},
{
amount: 200,
category: {
color: '#0055aa',
createdAt,
id: categoryId,
name: 'Groceries',
updatedAt
},
categoryId,
createdAt,
currency: 'USD',
id: 'budget-2',
month: '2026-06',
name: 'Emergency fund',
remaining: 200,
spent: 0,
type: 'CASH_SAVINGS',
updatedAt
}
],
totalBudgeted: 500,
totalRemaining: 375,
totalBudgeted: 700,
totalMonthlySavings: 200,
totalPlannedSpend: 500,
totalRemaining: 575,
totalSpent: 125
});
});
@ -299,8 +373,21 @@ describe('BudgetsService', () => {
id: categoryId,
userId
});
prismaService.budget.findFirst.mockResolvedValue(null);
prismaService.account.findFirst.mockResolvedValue({
id: accountId,
userId
});
prismaService.budget.create.mockResolvedValue({
account: {
balance: 0,
createdAt,
id: accountId,
isExcluded: false,
name: 'Checking',
updatedAt,
userId
},
accountId,
amount: 250,
category: {
createdAt,
@ -313,54 +400,73 @@ describe('BudgetsService', () => {
currency: 'USD',
id: budgetId,
month: new Date('2026-06-01T00:00:00.000Z'),
name: 'Train pass',
type: 'EXPENSE',
updatedAt,
userId
});
const response = await budgetsService.createBudget({
data: {
accountId,
amount: 250,
categoryId,
currency: 'USD',
month: '2026-06'
month: '2026-06',
name: 'Train pass',
type: 'EXPENSE'
},
userId
});
expect(prismaService.account.findFirst).toHaveBeenCalledWith({
where: {
id: accountId,
userId
}
});
expect(prismaService.budget.create).toHaveBeenCalledWith({
data: {
account: {
connect: {
id_userId: { id: accountId, userId }
}
},
amount: 250,
category: { connect: { id: categoryId } },
currency: 'USD',
month: new Date('2026-06-01T00:00:00.000Z'),
name: 'Train pass',
type: 'EXPENSE',
user: { connect: { id: userId } }
},
include: { category: true }
include: { account: true, category: true }
});
expect(response.remaining).toBe(250);
expect(response.spent).toBe(0);
});
it('rejects duplicate budgets for the same category and month', async () => {
it('rejects a budget account not owned by the current user', async () => {
prismaService.expenseCategory.findFirst.mockResolvedValue({
id: categoryId,
userId
});
prismaService.budget.findFirst.mockResolvedValue({
id: budgetId
});
prismaService.account.findFirst.mockResolvedValue(null);
await expect(
budgetsService.createBudget({
data: {
accountId,
amount: 250,
categoryId,
currency: 'USD',
month: '2026-06'
month: '2026-06',
name: 'Train pass',
type: 'EXPENSE'
},
userId
})
).rejects.toBeInstanceOf(ConflictException);
).rejects.toBeInstanceOf(ForbiddenException);
});
it('updates only a budget owned by the current user', async () => {
@ -373,6 +479,8 @@ describe('BudgetsService', () => {
userId
});
prismaService.budget.update.mockResolvedValue({
account: null,
accountId: null,
amount: 300,
category: {
createdAt,
@ -385,6 +493,8 @@ describe('BudgetsService', () => {
currency: 'USD',
id: budgetId,
month: new Date('2026-06-01T00:00:00.000Z'),
name: 'Train pass',
type: 'EXPENSE',
updatedAt,
userId
});
@ -395,7 +505,9 @@ describe('BudgetsService', () => {
categoryId,
currency: 'USD',
id: budgetId,
month: '2026-06'
month: '2026-06',
name: 'Train pass',
type: 'EXPENSE'
},
id: budgetId,
userId
@ -403,12 +515,15 @@ describe('BudgetsService', () => {
expect(prismaService.budget.update).toHaveBeenCalledWith({
data: {
account: { disconnect: true },
amount: 300,
category: { connect: { id: categoryId } },
currency: 'USD',
month: new Date('2026-06-01T00:00:00.000Z')
month: new Date('2026-06-01T00:00:00.000Z'),
name: 'Train pass',
type: 'EXPENSE'
},
include: { category: true },
include: { account: true, category: true },
where: { id: budgetId }
});
expect(response.amount).toBe(300);
@ -424,7 +539,9 @@ describe('BudgetsService', () => {
categoryId,
currency: 'USD',
id: budgetId,
month: '2026-06'
month: '2026-06',
name: 'Train pass',
type: 'EXPENSE'
},
id: budgetId,
userId

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

@ -7,6 +7,7 @@ import {
} from '@ghostfolio/common/dtos';
import {
BudgetResponse,
type BudgetType,
BudgetsResponse,
ExpenseCategoryResponse
} from '@ghostfolio/common/interfaces';
@ -16,9 +17,15 @@ import {
ForbiddenException,
Injectable
} from '@nestjs/common';
import type { Account } from '@prisma/client';
@Injectable()
export class BudgetsService {
private static readonly SAVINGS_BUDGET_TYPES: BudgetType[] = [
'CASH_SAVINGS',
'INVESTMENT_SAVINGS'
];
public constructor(private readonly prismaService: PrismaService) {}
public async createCategory({
@ -56,29 +63,35 @@ export class BudgetsService {
userId
});
const month = this.parseBudgetMonth(data.month);
const existingBudget = await this.prismaService.budget.findFirst({
where: {
categoryId: data.categoryId,
month,
if (data.accountId) {
await this.validateAccountOwnership({
accountId: data.accountId,
userId
}
});
if (existingBudget) {
throw new ConflictException();
});
}
const month = this.parseBudgetMonth(data.month);
const budget = await this.prismaService.budget.create({
data: {
...(data.accountId
? {
account: {
connect: {
id_userId: { id: data.accountId, userId }
}
}
}
: {}),
amount: data.amount,
category: { connect: { id: data.categoryId } },
currency: data.currency,
month,
name: data.name,
type: data.type,
user: { connect: { id: userId } }
},
include: { category: true }
include: { account: true, category: true }
});
return this.toBudgetResponse({ budget, spent: 0 });
@ -108,7 +121,7 @@ export class BudgetsService {
userId: string;
}): Promise<BudgetResponse> {
const budget = await this.prismaService.budget.findFirst({
include: { category: true },
include: { account: true, category: true },
where: { id, userId }
});
@ -151,15 +164,17 @@ export class BudgetsService {
const nextMonth = this.getNextMonth(budgetMonth);
const budgets = await this.prismaService.budget.findMany({
include: { category: true },
orderBy: { category: { name: 'asc' } },
include: { account: true, category: true },
orderBy: { name: 'asc' },
where: {
month: budgetMonth,
userId
}
});
const categoryIds = budgets.map(({ categoryId }) => categoryId);
const categoryIds = Array.from(
new Set(budgets.map(({ categoryId }) => categoryId))
);
const expenseSums = categoryIds.length
? await this.prismaService.expense.groupBy({
by: ['categoryId'],
@ -182,9 +197,13 @@ export class BudgetsService {
);
const budgetResponses = budgets.map((budget) => {
const isExpenseBudget = this.isPlannedSpendBudgetType(budget.type);
return this.toBudgetResponse({
budget,
spent: spentByCategoryId.get(budget.categoryId) ?? 0
spent: isExpenseBudget
? (spentByCategoryId.get(budget.categoryId) ?? 0)
: 0
});
});
@ -193,6 +212,14 @@ export class BudgetsService {
totalBudgeted: budgetResponses.reduce((sum, { amount }) => {
return sum + amount;
}, 0),
totalMonthlySavings: budgetResponses.reduce((sum, { amount, type }) => {
return BudgetsService.SAVINGS_BUDGET_TYPES.includes(type)
? sum + amount
: sum;
}, 0),
totalPlannedSpend: budgetResponses.reduce((sum, { amount, type }) => {
return this.isPlannedSpendBudgetType(type) ? sum + amount : sum;
}, 0),
totalRemaining: budgetResponses.reduce((sum, { remaining }) => {
return sum + remaining;
}, 0),
@ -217,16 +244,32 @@ export class BudgetsService {
userId
});
if (data.accountId) {
await this.validateAccountOwnership({
accountId: data.accountId,
userId
});
}
const month = this.parseBudgetMonth(data.month);
const budget = await this.prismaService.budget.update({
data: {
account: data.accountId
? {
connect: {
id_userId: { id: data.accountId, userId }
}
}
: { disconnect: true },
amount: data.amount,
category: { connect: { id: data.categoryId } },
currency: data.currency,
month
month,
name: data.name,
type: data.type
},
include: { category: true },
include: { account: true, category: true },
where: { id }
});
@ -270,6 +313,14 @@ export class BudgetsService {
);
}
private isPlannedSpendBudgetType(type: BudgetType) {
return [
'EXPENSE',
'LIABILITY_AUTOMATIC',
'YEARLY_EXPENSE_AUTOMATIC'
].includes(type);
}
private async getSpentForBudget({
categoryId,
month,
@ -305,6 +356,8 @@ export class BudgetsService {
}: {
budget: {
amount: number;
account?: Account | null;
accountId?: string | null;
category: {
color?: string;
createdAt: Date;
@ -317,11 +370,15 @@ export class BudgetsService {
currency: string;
id: string;
month: Date;
name: string;
type: BudgetType;
updatedAt: Date;
};
spent: number;
}): BudgetResponse {
return {
account: budget.account ?? undefined,
accountId: budget.accountId ?? undefined,
amount: budget.amount,
category: budget.category,
categoryId: budget.categoryId,
@ -329,8 +386,10 @@ export class BudgetsService {
currency: budget.currency,
id: budget.id,
month: this.formatBudgetMonth(budget.month),
name: budget.name || budget.category.name,
remaining: budget.amount - spent,
spent,
type: budget.type,
updatedAt: budget.updatedAt
};
}
@ -367,6 +426,25 @@ export class BudgetsService {
}
}
private async validateAccountOwnership({
accountId,
userId
}: {
accountId: string;
userId: string;
}) {
const account = await this.prismaService.account.findFirst({
where: {
id: accountId,
userId
}
});
if (!account) {
throw new ForbiddenException();
}
}
private async validateCategoryOwnership({
categoryId,
userId

55
apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts

@ -40,6 +40,33 @@ jest.mock('@ghostfolio/ui/value', () => {
return { GfValueComponent };
});
jest.mock('@ionic/angular/standalone', () => {
const { Component, Input } = require('@angular/core');
@Component({
selector: 'ion-icon',
template: ''
})
class IonIcon {
@Input() public name: string;
}
return { IonIcon };
});
jest.mock('ionicons', () => {
return {
addIcons: jest.fn()
};
});
jest.mock('ionicons/icons', () => {
return {
createOutline: {},
trashOutline: {}
};
});
const {
UserService
} = require('@ghostfolio/client/services/user/user.service');
@ -59,6 +86,16 @@ describe('GfBudgetPageComponent', () => {
of({
budgets: [
{
account: {
balance: 0,
createdAt: new Date('2026-06-01'),
id: 'checking',
isExcluded: false,
name: 'Checking',
updatedAt: new Date('2026-06-01'),
userId: 'user-1'
},
accountId: 'checking',
amount: 500,
category: {
id: 'food',
@ -69,12 +106,16 @@ describe('GfBudgetPageComponent', () => {
currency: 'USD',
id: 'budget-1',
month: '2026-06',
name: 'Food shop',
remaining: 125,
spent: 375,
type: 'EXPENSE',
updatedAt: new Date('2026-06-01')
}
],
totalBudgeted: 500,
totalMonthlySavings: 0,
totalPlannedSpend: 500,
totalRemaining: 125,
totalSpent: 375
})
@ -126,7 +167,18 @@ describe('GfBudgetPageComponent', () => {
month: expect.stringMatching(/^\d{4}-\d{2}$/)
});
expect(fixture.nativeElement.textContent).toContain('Budget');
expect(fixture.nativeElement.textContent).toContain('Food shop');
expect(fixture.nativeElement.textContent).toContain('Food');
expect(fixture.nativeElement.textContent).toContain('Checking');
expect(
fixture.nativeElement.querySelector('[aria-label="Edit budget"] ion-icon')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector(
'[aria-label="Delete budget"] ion-icon'
)
).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('editdelete');
});
it('reloads budgets after creating a budget', async () => {
@ -163,7 +215,8 @@ describe('GfBudgetPageComponent', () => {
await fixture.whenStable();
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
width: '36rem'
maxWidth: 'calc(100vw - 2rem)',
width: '42rem'
});
});
});

62
apps/client/src/app/pages/portfolio/budget/budget-page.component.ts

@ -1,5 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { BudgetResponse, User } from '@ghostfolio/common/interfaces';
import type { BudgetResponse, User } from '@ghostfolio/common/interfaces';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -14,10 +14,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone';
import { format } from 'date-fns';
import { addIcons } from 'ionicons';
import { createOutline, trashOutline } from 'ionicons/icons';
import { GfCreateOrUpdateBudgetDialogComponent } from './create-or-update-budget-dialog/create-or-update-budget-dialog.component';
import { GfManageBudgetCategoriesDialogComponent } from './manage-budget-categories-dialog/manage-budget-categories-dialog.component';
@ -27,8 +29,8 @@ import { GfManageBudgetCategoriesDialogComponent } from './manage-budget-categor
imports: [
CommonModule,
GfValueComponent,
IonIcon,
MatButtonModule,
MatIconModule,
MatProgressBarModule,
MatTableModule,
ReactiveFormsModule
@ -40,7 +42,10 @@ import { GfManageBudgetCategoriesDialogComponent } from './manage-budget-categor
export class GfBudgetPageComponent implements OnInit {
public dataSource = new MatTableDataSource<BudgetResponse>([]);
public displayedColumns = [
'name',
'category',
'account',
'type',
'amount',
'spent',
'remaining',
@ -52,6 +57,8 @@ export class GfBudgetPageComponent implements OnInit {
nonNullable: true
});
public totalBudgeted = 0;
public totalMonthlySavings = 0;
public totalPlannedSpend = 0;
public totalRemaining = 0;
public totalSpent = 0;
public user: User;
@ -62,7 +69,9 @@ export class GfBudgetPageComponent implements OnInit {
private destroyRef: DestroyRef,
private dialog: MatDialog,
private userService: UserService
) {}
) {
addIcons({ createOutline, trashOutline });
}
public ngOnInit() {
this.userService.stateChanged
@ -89,14 +98,25 @@ export class GfBudgetPageComponent implements OnInit {
this.dataService
.fetchBudgets({ month: this.monthControl.value })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ budgets, totalBudgeted, totalRemaining, totalSpent }) => {
this.dataSource = new MatTableDataSource(budgets);
this.totalBudgeted = totalBudgeted;
this.totalRemaining = totalRemaining;
this.totalSpent = totalSpent;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
.subscribe(
({
budgets,
totalBudgeted,
totalMonthlySavings,
totalPlannedSpend,
totalRemaining,
totalSpent
}) => {
this.dataSource = new MatTableDataSource(budgets);
this.totalBudgeted = totalBudgeted;
this.totalMonthlySavings = totalMonthlySavings;
this.totalPlannedSpend = totalPlannedSpend;
this.totalRemaining = totalRemaining;
this.totalSpent = totalSpent;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}
);
}
public getProgress({ amount, spent }: BudgetResponse) {
@ -107,6 +127,21 @@ export class GfBudgetPageComponent implements OnInit {
return Math.min((spent / amount) * 100, 100);
}
public getBudgetTypeLabel(type: BudgetResponse['type']) {
switch (type) {
case 'CASH_SAVINGS':
return $localize`Cash savings`;
case 'INVESTMENT_SAVINGS':
return $localize`Investment savings`;
case 'LIABILITY_AUTOMATIC':
return $localize`Liability`;
case 'YEARLY_EXPENSE_AUTOMATIC':
return $localize`Yearly expense`;
default:
return $localize`Expense`;
}
}
public onCreateBudget() {
this.openBudgetDialog({
currency: this.getCurrency(),
@ -126,7 +161,8 @@ export class GfBudgetPageComponent implements OnInit {
public onManageCategories() {
this.dialog
.open(GfManageBudgetCategoriesDialogComponent, {
width: '36rem'
maxWidth: 'calc(100vw - 2rem)',
width: '42rem'
})
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))

187
apps/client/src/app/pages/portfolio/budget/budget-page.html

@ -44,91 +44,120 @@
</div>
@if (dataSource.data.length > 0) {
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="category">
<th *matHeaderCellDef i18n mat-header-cell>Category</th>
<td *matCellDef="let budget" mat-cell>
{{ budget.category?.name ?? budget.categoryId }}
</td>
</ng-container>
<div class="budget-table-wrapper">
<table
class="budget-table gf-table w-100"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th *matHeaderCellDef i18n mat-header-cell>Item</th>
<td *matCellDef="let budget" mat-cell>
{{ budget.name }}
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Budget
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.amount"
/>
</td>
</ng-container>
<ng-container matColumnDef="category">
<th *matHeaderCellDef i18n mat-header-cell>Category</th>
<td *matCellDef="let budget" mat-cell>
{{ budget.category?.name ?? budget.categoryId }}
</td>
</ng-container>
<ng-container matColumnDef="spent">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Spent
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.spent"
/>
</td>
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef i18n mat-header-cell>Account</th>
<td *matCellDef="let budget" mat-cell>
{{ budget.account?.name ?? '-' }}
</td>
</ng-container>
<ng-container matColumnDef="remaining">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Remaining
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.remaining"
/>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef i18n mat-header-cell>Type</th>
<td *matCellDef="let budget" mat-cell>
{{ getBudgetTypeLabel(budget.type) }}
</td>
</ng-container>
<ng-container matColumnDef="progress">
<th *matHeaderCellDef i18n mat-header-cell>Progress</th>
<td *matCellDef="let budget" mat-cell>
<mat-progress-bar
mode="determinate"
[value]="getProgress(budget)"
/>
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Budget
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.amount"
/>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="text-right" mat-header-cell></th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<button
mat-icon-button
type="button"
[attr.aria-label]="'Edit budget'"
(click)="onUpdateBudget(budget)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
type="button"
[attr.aria-label]="'Delete budget'"
(click)="onDeleteBudget(budget.id)"
>
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<ng-container matColumnDef="spent">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Spent
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.spent"
/>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
<ng-container matColumnDef="remaining">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Remaining
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.remaining"
/>
</td>
</ng-container>
<ng-container matColumnDef="progress">
<th *matHeaderCellDef i18n mat-header-cell>Progress</th>
<td *matCellDef="let budget" mat-cell>
<mat-progress-bar
mode="determinate"
[value]="getProgress(budget)"
/>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="text-right" mat-header-cell></th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<button
class="budget-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Edit budget'"
(click)="onUpdateBudget(budget)"
>
<ion-icon aria-hidden="true" name="create-outline" />
</button>
<button
class="budget-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Delete budget'"
(click)="onDeleteBudget(budget.id)"
>
<ion-icon aria-hidden="true" name="trash-outline" />
</button>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
} @else if (!isLoading) {
<div class="py-5 text-center text-muted" i18n>
No budgets have been created for this month.

23
apps/client/src/app/pages/portfolio/budget/budget-page.scss

@ -17,3 +17,26 @@
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.budget-table-wrapper {
overflow-x: auto;
}
.budget-table {
min-width: 56rem;
}
.budget-table .mat-column-actions {
white-space: nowrap;
width: 6rem;
}
.budget-action-button {
height: 2.25rem;
line-height: 2.25rem;
width: 2.25rem;
}
.budget-action-button ion-icon {
font-size: 1.125rem;
}

60
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts

@ -10,12 +10,22 @@ import {
GfCreateOrUpdateBudgetDialogComponent
} from './create-or-update-budget-dialog.component';
(global as any).$localize = (
messageParts: TemplateStringsArray,
...expressions: any[]
) => {
return String.raw({ raw: messageParts }, ...expressions);
};
describe('GfCreateOrUpdateBudgetDialogComponent', () => {
let component: GfCreateOrUpdateBudgetDialogComponent;
let dataService: jest.Mocked<
Pick<
DataService,
'createBudget' | 'fetchExpenseCategories' | 'updateBudget'
| 'createBudget'
| 'fetchAccounts'
| 'fetchExpenseCategories'
| 'updateBudget'
>
>;
let dialogRef: jest.Mocked<
@ -27,12 +37,30 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
dataService = {
createBudget: jest.fn().mockReturnValue(
of({
accountId: 'checking',
amount: 500,
categoryId: 'food',
id: 'budget-1',
month: '2026-06',
name: 'Food shop',
remaining: 500,
spent: 0
spent: 0,
type: 'EXPENSE'
})
),
fetchAccounts: jest.fn().mockReturnValue(
of({
accounts: [
{
balance: 0,
createdAt: new Date('2026-06-01'),
id: 'checking',
isExcluded: false,
name: 'Checking',
updatedAt: new Date('2026-06-01'),
userId: 'user-1'
}
]
})
),
fetchExpenseCategories: jest.fn().mockReturnValue(
@ -48,12 +76,15 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
),
updateBudget: jest.fn().mockReturnValue(
of({
accountId: 'checking',
amount: 650,
categoryId: 'food',
id: 'budget-1',
month: '2026-06',
name: 'Food shop',
remaining: 650,
spent: 0
spent: 0,
type: 'EXPENSE'
})
)
};
@ -92,20 +123,27 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
});
component.budgetForm.setValue({
accountId: 'checking',
amount: 500,
categoryId: 'food',
month: '2026-06'
month: '2026-06',
name: 'Food shop',
type: 'EXPENSE'
});
component.onSubmit();
await fixture.whenStable();
expect(dataService.fetchExpenseCategories).toHaveBeenCalled();
expect(dataService.fetchAccounts).toHaveBeenCalled();
expect(dataService.createBudget).toHaveBeenCalledWith({
accountId: 'checking',
amount: 500,
categoryId: 'food',
currency: 'USD',
month: '2026-06'
month: '2026-06',
name: 'Food shop',
type: 'EXPENSE'
});
expect(dialogRef.close).toHaveBeenCalledWith({ refresh: true });
});
@ -113,6 +151,7 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
it('updates a budget and closes with refresh', async () => {
await setup({
budget: {
accountId: 'checking',
amount: 500,
category: {
id: 'food',
@ -123,8 +162,10 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
currency: 'USD',
id: 'budget-1',
month: '2026-06',
name: 'Food shop',
remaining: 500,
spent: 0,
type: 'EXPENSE',
updatedAt: new Date('2026-06-01')
},
currency: 'USD',
@ -132,9 +173,12 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
});
component.budgetForm.setValue({
accountId: '',
amount: 650,
categoryId: 'food',
month: '2026-06'
month: '2026-06',
name: 'Food shop',
type: 'EXPENSE'
});
component.onSubmit();
@ -146,7 +190,9 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
categoryId: 'food',
currency: 'USD',
id: 'budget-1',
month: '2026-06'
month: '2026-06',
name: 'Food shop',
type: 'EXPENSE'
},
id: 'budget-1'
});

49
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts

@ -1,8 +1,12 @@
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos';
import {
BudgetResponse,
ExpenseCategoryResponse
} from '@ghostfolio/common/interfaces';
import type {
CreateBudgetDto,
ManualBudgetType
} from '@ghostfolio/common/dtos/create-budget.dto';
import type { UpdateBudgetDto } from '@ghostfolio/common/dtos/update-budget.dto';
import type { AccountsResponse } from '@ghostfolio/common/interfaces/responses/accounts-response.interface';
import type { BudgetResponse } from '@ghostfolio/common/interfaces/responses/budget-response.interface';
import type { ExpenseCategoryResponse } from '@ghostfolio/common/interfaces/responses/expense-category-response.interface';
import type { AccountWithValue } from '@ghostfolio/common/types';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
@ -46,6 +50,9 @@ export interface CreateOrUpdateBudgetDialogData {
})
export class GfCreateOrUpdateBudgetDialogComponent implements OnInit {
public budgetForm = new FormGroup({
accountId: new FormControl<string>(this.data.budget?.accountId ?? '', {
nonNullable: true
}),
amount: new FormControl<number>(this.data.budget?.amount ?? 0, {
nonNullable: true,
validators: [Validators.required, Validators.min(0.01)]
@ -57,9 +64,27 @@ export class GfCreateOrUpdateBudgetDialogComponent implements OnInit {
month: new FormControl<string>(this.data.budget?.month ?? this.data.month, {
nonNullable: true,
validators: [Validators.required]
})
}),
name: new FormControl<string>(this.data.budget?.name ?? '', {
nonNullable: true,
validators: [Validators.required]
}),
type: new FormControl<ManualBudgetType>(
(this.data.budget?.type as ManualBudgetType) ?? 'EXPENSE',
{
nonNullable: true,
validators: [Validators.required]
}
)
});
public accounts: AccountWithValue[] = [];
public budgetTypes: Array<{ label: string; value: ManualBudgetType }> = [
{ label: $localize`Expense`, value: 'EXPENSE' },
{ label: $localize`Cash savings`, value: 'CASH_SAVINGS' },
{ label: $localize`Investment savings`, value: 'INVESTMENT_SAVINGS' }
];
public categories: ExpenseCategoryResponse[] = [];
public isLoadingAccounts = true;
public isLoadingCategories = true;
public constructor(
@ -70,6 +95,14 @@ export class GfCreateOrUpdateBudgetDialogComponent implements OnInit {
) {}
public ngOnInit() {
this.dataService
.fetchAccounts()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accounts }: AccountsResponse) => {
this.accounts = accounts;
this.isLoadingAccounts = false;
});
this.dataService
.fetchExpenseCategories()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -85,8 +118,10 @@ export class GfCreateOrUpdateBudgetDialogComponent implements OnInit {
return;
}
const { accountId, ...formValue } = this.budgetForm.getRawValue();
const budget = {
...this.budgetForm.getRawValue(),
...formValue,
...(accountId ? { accountId } : {}),
currency: this.data.currency
};

32
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html

@ -8,11 +8,27 @@
<form [formGroup]="budgetForm" (ngSubmit)="onSubmit()">
<mat-dialog-content class="budget-dialog-content">
<mat-form-field appearance="outline">
<mat-label i18n>Item name</mat-label>
<input formControlName="name" matInput />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Month</mat-label>
<input formControlName="month" matInput type="month" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Type</mat-label>
<mat-select formControlName="type">
@for (budgetType of budgetTypes; track budgetType.value) {
<mat-option [value]="budgetType.value">
{{ budgetType.label }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Category</mat-label>
<mat-select formControlName="categoryId">
@ -30,6 +46,22 @@
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Account</mat-label>
<mat-select formControlName="accountId">
<mat-option i18n value="">No account</mat-option>
@if (isLoadingAccounts) {
<mat-option disabled i18n>Loading accounts...</mat-option>
} @else {
@for (account of accounts; track account.id) {
<mat-option [value]="account.id">
{{ account.name ?? account.id }}
</mat-option>
}
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Amount</mat-label>
<input

100
apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts

@ -7,6 +7,33 @@ import { of } from 'rxjs';
import { GfManageBudgetCategoriesDialogComponent } from './manage-budget-categories-dialog.component';
jest.mock('@ionic/angular/standalone', () => {
const { Component, Input } = require('@angular/core');
@Component({
selector: 'ion-icon',
template: ''
})
class IonIcon {
@Input() public name: string;
}
return { IonIcon };
});
jest.mock('ionicons', () => {
return {
addIcons: jest.fn()
};
});
jest.mock('ionicons/icons', () => {
return {
createOutline: {},
trashOutline: {}
};
});
describe('GfManageBudgetCategoriesDialogComponent', () => {
const createdAt = new Date('2026-06-01');
const updatedAt = new Date('2026-06-01');
@ -85,6 +112,17 @@ describe('GfManageBudgetCategoriesDialogComponent', () => {
it('loads expense categories', async () => {
await fixture.whenStable();
expect(
fixture.nativeElement.querySelector(
'[aria-label="Edit category"] ion-icon'
)
).not.toBeNull();
expect(
fixture.nativeElement.querySelector(
'[aria-label="Delete category"] ion-icon'
)
).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('editdelete');
expect(dataService.fetchExpenseCategories).toHaveBeenCalled();
expect(component.dataSource.data).toEqual([
{
@ -115,6 +153,68 @@ describe('GfManageBudgetCategoriesDialogComponent', () => {
expect(dataService.fetchExpenseCategories).toHaveBeenCalledTimes(2);
});
it('resets the category form interaction state after creating a category', async () => {
await fixture.whenStable();
component.categoryForm.setValue({
color: '#0055aa',
name: 'Transport'
});
component.categoryForm.markAllAsTouched();
component.onSubmit();
await fixture.whenStable();
expect(component.categoryForm.controls.name.value).toBe('');
expect(component.categoryForm.controls.name.touched).toBe(false);
expect(component.categoryForm.controls.name.dirty).toBe(false);
});
it('does not show the name field as invalid after creating a category', async () => {
await fixture.whenStable();
component.categoryForm.setValue({
color: '#0055aa',
name: 'Transport'
});
const form: HTMLFormElement =
fixture.nativeElement.querySelector('.category-form');
form.dispatchEvent(new Event('submit'));
await fixture.whenStable();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'.category-name-field.mat-form-field-invalid'
)
).toBeNull();
});
it('updates the color form value from the color picker', async () => {
await fixture.whenStable();
const colorInput: HTMLInputElement = fixture.nativeElement.querySelector(
'input[type="color"]'
);
colorInput.value = '#aa5500';
colorInput.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.categoryForm.controls.color.value).toBe('#aa5500');
expect(fixture.nativeElement.textContent).toContain('#aa5500');
});
it('clears the selected category color', async () => {
await fixture.whenStable();
component.onClearColor();
expect(component.categoryForm.controls.color.value).toBe('');
});
it('updates an expense category and reloads categories', async () => {
await fixture.whenStable();

40
apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts

@ -12,23 +12,26 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormControl,
FormGroup,
FormGroupDirective,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { createOutline, trashOutline } from 'ionicons/icons';
@Component({
imports: [
CommonModule,
IonIcon,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatTableModule,
ReactiveFormsModule
@ -39,7 +42,7 @@ import { MatTableDataSource, MatTableModule } from '@angular/material/table';
})
export class GfManageBudgetCategoriesDialogComponent implements OnInit {
public categoryForm = new FormGroup({
color: new FormControl<string>('', {
color: new FormControl<string>('#0055aa', {
nonNullable: true,
validators: [Validators.pattern(/^#[0-9a-fA-F]{6}$/)]
}),
@ -58,18 +61,27 @@ export class GfManageBudgetCategoriesDialogComponent implements OnInit {
private dataService: DataService,
private destroyRef: DestroyRef,
private dialogRef: MatDialogRef<GfManageBudgetCategoriesDialogComponent>
) {}
) {
addIcons({ createOutline, trashOutline });
}
public ngOnInit() {
this.fetchCategories();
}
public onCancelEdit() {
public onCancelEdit(formDirective?: FormGroupDirective) {
this.editingCategory = undefined;
this.categoryForm.reset({
color: '',
const formValue = {
color: '#0055aa',
name: ''
});
};
if (formDirective) {
formDirective.resetForm(formValue);
} else {
this.categoryForm.reset(formValue);
}
}
public onClose() {
@ -91,9 +103,15 @@ export class GfManageBudgetCategoriesDialogComponent implements OnInit {
color: category.color ?? '',
name: category.name
});
this.categoryForm.markAsPristine();
this.categoryForm.markAsUntouched();
}
public onClearColor() {
this.categoryForm.controls.color.setValue('');
}
public onSubmit() {
public onSubmit(formDirective?: FormGroupDirective) {
if (this.categoryForm.invalid) {
this.categoryForm.markAllAsTouched();
return;
@ -112,7 +130,7 @@ export class GfManageBudgetCategoriesDialogComponent implements OnInit {
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.onCancelEdit();
this.onCancelEdit(formDirective);
this.fetchCategories();
});
} else {
@ -120,7 +138,7 @@ export class GfManageBudgetCategoriesDialogComponent implements OnInit {
.createExpenseCategory(category)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.onCancelEdit();
this.onCancelEdit(formDirective);
this.fetchCategories();
});
}

72
apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html

@ -2,19 +2,55 @@
<mat-dialog-content>
<form
#categoryFormDirective="ngForm"
class="category-form"
[formGroup]="categoryForm"
(ngSubmit)="onSubmit()"
(ngSubmit)="onSubmit(categoryFormDirective)"
>
<mat-form-field appearance="outline">
<mat-form-field appearance="outline" class="category-name-field">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Color</mat-label>
<input formControlName="color" matInput placeholder="#0055aa" />
</mat-form-field>
<div class="category-color-picker-field">
<label class="category-color-picker-label" for="category-color" i18n>
Color
</label>
<div class="category-color-picker-control">
<span class="category-color-picker-swatch-wrap">
<span
class="category-color-picker-swatch"
[style.background-color]="
categoryForm.controls.color.value || '#0055aa'
"
></span>
<input
aria-label="Category color"
class="category-color-picker"
id="category-color"
type="color"
[value]="categoryForm.controls.color.value || '#0055aa'"
(input)="
categoryForm.controls.color.setValue($any($event.target).value)
"
/>
</span>
<span class="category-color-picker-value">
{{ categoryForm.controls.color.value || '#0055aa' }}
</span>
@if (categoryForm.controls.color.value) {
<button
class="category-clear-color-button"
i18n
mat-button
type="button"
(click)="onClearColor()"
>
Clear
</button>
}
</div>
</div>
<div class="category-form-actions">
@if (editingCategory) {
@ -40,7 +76,11 @@
</form>
@if (dataSource.data.length > 0) {
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<table
class="category-table gf-table w-100"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th *matHeaderCellDef i18n mat-header-cell>Name</th>
<td *matCellDef="let category" mat-cell>{{ category.name }}</td>
@ -62,23 +102,33 @@
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="text-right" mat-header-cell></th>
<td *matCellDef="let category" class="text-right" mat-cell>
<th
*matHeaderCellDef
class="category-actions-column text-right"
mat-header-cell
></th>
<td
*matCellDef="let category"
class="category-actions-column text-right"
mat-cell
>
<button
class="category-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Edit category'"
(click)="onEditCategory(category)"
>
<mat-icon>edit</mat-icon>
<ion-icon aria-hidden="true" name="create-outline" />
</button>
<button
class="category-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Delete category'"
(click)="onDeleteCategory(category.id)"
>
<mat-icon>delete</mat-icon>
<ion-icon aria-hidden="true" name="trash-outline" />
</button>
</td>
</ng-container>

137
apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss

@ -1,25 +1,147 @@
:host {
display: block;
}
.category-form {
align-items: flex-start;
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(0, 10rem) auto;
gap: 1rem 1rem;
grid-template-columns: minmax(14rem, 1fr) minmax(14rem, 16rem) auto;
padding-top: 0.75rem;
}
.category-form-actions {
align-items: center;
align-items: flex-start;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 0.6875rem;
}
.category-name-field {
width: 100%;
}
.category-color-picker-field {
position: relative;
}
.category-color-picker-label {
background-color: var(--light-background);
color: rgba(var(--palette-foreground-text), 0.72);
font-size: 0.75rem;
left: 0.75rem;
line-height: 1rem;
padding: 0 0.25rem;
position: absolute;
top: -0.5rem;
z-index: 1;
}
:host-context(.theme-dark) .category-color-picker-label {
background-color: var(--dark-background);
color: rgba(var(--palette-foreground-text-dark), 0.72);
}
.category-color-picker-control {
align-items: center;
border: 1px solid rgba(var(--palette-foreground-divider), 0.32);
border-radius: 0.25rem;
display: flex;
gap: 0.75rem;
height: 3.5rem;
padding: 0 0.75rem;
}
.category-color-picker-control:focus-within {
border-color: #1ec8c8;
box-shadow: inset 0 0 0 1px #1ec8c8;
}
.category-color-picker-swatch-wrap {
border-radius: 0.25rem;
display: inline-flex;
flex: 0 0 auto;
height: 2rem;
overflow: hidden;
position: relative;
width: 2rem;
}
.category-color-picker-swatch {
border: 1px solid rgba(var(--palette-foreground-divider), 0.24);
border-radius: 0.25rem;
display: block;
height: 100%;
width: 100%;
}
.category-color-picker {
appearance: none;
-webkit-appearance: none;
background: none;
border: 0;
cursor: pointer;
inset: 0;
opacity: 0;
padding: 0;
position: absolute;
width: 100%;
}
.category-color-picker-value {
font-family: monospace;
font-size: 0.875rem;
white-space: nowrap;
}
.category-clear-color-button {
margin-left: auto;
min-width: auto;
padding: 0 0.5rem;
}
.category-table {
table-layout: fixed;
}
.category-table .mat-column-name {
width: 45%;
}
.category-table .mat-column-color {
width: 35%;
}
.category-actions-column {
white-space: nowrap;
width: 6rem;
}
.category-action-button {
height: 2.25rem;
line-height: 2.25rem;
width: 2.25rem;
}
.category-action-button ion-icon {
font-size: 1.125rem;
}
.category-color {
align-items: center;
display: inline-flex;
gap: 0.5rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-color-swatch {
border: 1px solid var(--border-color);
display: inline-block;
flex: 0 0 auto;
height: 1rem;
width: 1rem;
}
@ -31,5 +153,14 @@
.category-form-actions {
justify-content: flex-start;
min-height: auto;
}
.category-table {
min-width: 32rem;
}
mat-dialog-content {
overflow-x: auto;
}
}

547
docs/superpowers/plans/2026-06-24-budget-ui-repair.md

@ -0,0 +1,547 @@
# Budget UI Repair 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:** Fix the broken Budget page category management UI so the dialog is readable, actions render as compact icons, and the layout works in dark mode at desktop and narrow widths.
**Architecture:** Keep the existing Angular Material dialog/table pattern, but stop relying on Material icon font ligatures for action buttons in the Budget feature. Use Ghostfolio's existing Ionic icon pattern for edit/delete actions, constrain action columns, and make the category form/table responsive with stable widths.
**Tech Stack:** Angular 21 standalone components, Angular Material dialog/table/form controls, Ionic standalone icons, Jest, Nx.
---
## Current Breakage From Screenshots
- In `Manage categories`, action icons render as visible text (`edit delete`) and overflow the table row.
- The dialog content is too narrow for the form plus action column, so controls feel cramped and clipped.
- The category table action column has no stable width, so icon/text fallback can steal space from data columns.
- The Budget page uses the same `mat-icon` pattern for budget row edit/delete actions, so it should be fixed in the same pass.
## File Structure
- Modify `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts`: replace `MatIconModule` dependency with `IonIcon`, register `createOutline` and `trashOutline`.
- Modify `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html`: replace `<mat-icon>` action content with `<ion-icon>`, add action button classes, and improve table/action markup.
- Modify `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss`: add responsive dialog form layout, stable action column/button sizing, color swatch truncation, and mobile table behavior.
- Modify `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts`: assert icon buttons exist by aria labels, form actions remain usable, and category color renders without exposing raw icon ligature text.
- Modify `apps/client/src/app/pages/portfolio/budget/budget-page.component.ts`: replace `MatIconModule` dependency with `IonIcon`, register `createOutline` and `trashOutline`, widen category dialog.
- Modify `apps/client/src/app/pages/portfolio/budget/budget-page.html`: replace budget row `<mat-icon>` actions with Ionic icons and stable action button classes.
- Modify `apps/client/src/app/pages/portfolio/budget/budget-page.scss`: add stable table action sizing and horizontal overflow handling for dense budget rows.
- Modify `apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts`: assert budget row actions render without raw icon words and dialog opens with the wider dimensions.
---
## Task 1: Replace Broken Material Icon Ligatures In Category Dialog
**Files:**
- Modify: `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts`
- Modify: `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html`
- Modify: `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts`
- [ ] **Step 1: Write the failing icon rendering test**
Add this assertion to `it('loads expense categories', ...)` after `await fixture.whenStable();`:
```ts
expect(
fixture.nativeElement.querySelector('[aria-label="Edit category"] ion-icon')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('[aria-label="Delete category"] ion-icon')
).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('editdelete');
```
- [ ] **Step 2: Run the category dialog spec and confirm it fails**
Run:
```bash
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts --runInBand --watchman=false
```
Expected: FAIL because the template still uses `<mat-icon>` instead of `<ion-icon>`.
- [ ] **Step 3: Replace Material icons with Ionic icons in the component**
In `manage-budget-categories-dialog.component.ts`, replace the `MatIconModule` import and component dependency with Ionic icons:
```ts
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { createOutline, trashOutline } from 'ionicons/icons';
```
Remove:
```ts
import { MatIconModule } from '@angular/material/icon';
```
Update `imports`:
```ts
imports: [
CommonModule,
IonIcon,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatTableModule,
ReactiveFormsModule
],
```
Add this constructor body after dependency injection parameters:
```ts
) {
addIcons({ createOutline, trashOutline });
}
```
- [ ] **Step 4: Replace the category action button markup**
In `manage-budget-categories-dialog.html`, replace the two `<mat-icon>` button bodies with:
```html
<button
class="category-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Edit category'"
(click)="onEditCategory(category)"
>
<ion-icon aria-hidden="true" name="create-outline" />
</button>
<button
class="category-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Delete category'"
(click)="onDeleteCategory(category.id)"
>
<ion-icon aria-hidden="true" name="trash-outline" />
</button>
```
- [ ] **Step 5: Run the category dialog spec and confirm it passes**
Run:
```bash
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts --runInBand --watchman=false
```
Expected: PASS.
---
## Task 2: Repair Category Dialog Layout And Responsiveness
**Files:**
- Modify: `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html`
- Modify: `apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss`
- Modify: `apps/client/src/app/pages/portfolio/budget/budget-page.component.ts`
- [ ] **Step 1: Widen the dialog safely**
In `budget-page.component.ts`, update `onManageCategories()`:
```ts
this.dialog
.open(GfManageBudgetCategoriesDialogComponent, {
maxWidth: 'calc(100vw - 2rem)',
width: '42rem'
})
```
- [ ] **Step 2: Add stable table classes in the category template**
In `manage-budget-categories-dialog.html`, update the table element:
```html
<table
class="category-table gf-table w-100"
mat-table
[dataSource]="dataSource"
>
```
Update the actions column cells:
```html
<th
*matHeaderCellDef
class="category-actions-column text-right"
mat-header-cell
></th>
<td
*matCellDef="let category"
class="category-actions-column text-right"
mat-cell
>
```
- [ ] **Step 3: Add defensive layout CSS**
Replace `manage-budget-categories-dialog.scss` with:
```scss
:host {
display: block;
}
.category-form {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: minmax(12rem, 1fr) minmax(8rem, 10rem) auto;
}
.category-form-actions {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
min-height: 3.5rem;
}
.category-table {
table-layout: fixed;
}
.category-table .mat-column-name {
width: 45%;
}
.category-table .mat-column-color {
width: 35%;
}
.category-actions-column {
white-space: nowrap;
width: 6rem;
}
.category-action-button {
height: 2.25rem;
line-height: 2.25rem;
width: 2.25rem;
}
.category-action-button ion-icon {
font-size: 1.125rem;
}
.category-color {
align-items: center;
display: inline-flex;
gap: 0.5rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-color-swatch {
border: 1px solid var(--border-color);
display: inline-block;
flex: 0 0 auto;
height: 1rem;
width: 1rem;
}
@media (max-width: 767.98px) {
.category-form {
grid-template-columns: 1fr;
}
.category-form-actions {
justify-content: flex-start;
min-height: auto;
}
.category-table {
min-width: 32rem;
}
mat-dialog-content {
overflow-x: auto;
}
}
```
- [ ] **Step 4: Update the budget page dialog width test**
In `budget-page.component.spec.ts`, update the category dialog expectation to:
```ts
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
maxWidth: 'calc(100vw - 2rem)',
width: '42rem'
});
```
- [ ] **Step 5: Run the page and category specs**
Run:
```bash
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts --runInBand --watchman=false
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts --runInBand --watchman=false
```
Expected: both PASS.
---
## Task 3: Fix Budget Table Row Action Icons And Dense Table Overflow
**Files:**
- Modify: `apps/client/src/app/pages/portfolio/budget/budget-page.component.ts`
- Modify: `apps/client/src/app/pages/portfolio/budget/budget-page.html`
- Modify: `apps/client/src/app/pages/portfolio/budget/budget-page.scss`
- Modify: `apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts`
- [ ] **Step 1: Add failing budget action icon assertions**
In `budget-page.component.spec.ts`, inside `it('loads and renders budgets for the selected month', ...)`, add:
```ts
expect(
fixture.nativeElement.querySelector('[aria-label="Edit budget"] ion-icon')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('[aria-label="Delete budget"] ion-icon')
).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('editdelete');
```
- [ ] **Step 2: Run the budget page spec and confirm it fails**
Run:
```bash
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts --runInBand --watchman=false
```
Expected: FAIL because budget row actions still use `<mat-icon>`.
- [ ] **Step 3: Replace Material icon dependency in the budget page**
In `budget-page.component.ts`, add:
```ts
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { createOutline, trashOutline } from 'ionicons/icons';
```
Remove:
```ts
import { MatIconModule } from '@angular/material/icon';
```
Update `imports`:
```ts
imports: [
CommonModule,
GfValueComponent,
IonIcon,
MatButtonModule,
MatProgressBarModule,
MatTableModule,
ReactiveFormsModule
],
```
Add this constructor body after dependency injection parameters:
```ts
) {
addIcons({ createOutline, trashOutline });
}
```
- [ ] **Step 4: Replace budget row action markup**
In `budget-page.html`, replace the two budget action button bodies with:
```html
<button
class="budget-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Edit budget'"
(click)="onUpdateBudget(budget)"
>
<ion-icon aria-hidden="true" name="create-outline" />
</button>
<button
class="budget-action-button"
mat-icon-button
type="button"
[attr.aria-label]="'Delete budget'"
(click)="onDeleteBudget(budget.id)"
>
<ion-icon aria-hidden="true" name="trash-outline" />
</button>
```
- [ ] **Step 5: Add stable budget table action CSS**
Append to `budget-page.scss`:
```scss
.budget-table-wrapper {
overflow-x: auto;
}
.budget-table {
min-width: 56rem;
}
.budget-table .mat-column-actions {
white-space: nowrap;
width: 6rem;
}
.budget-action-button {
height: 2.25rem;
line-height: 2.25rem;
width: 2.25rem;
}
.budget-action-button ion-icon {
font-size: 1.125rem;
}
```
Wrap the table in `budget-page.html`:
```html
<div class="budget-table-wrapper">
<table class="budget-table gf-table w-100" mat-table [dataSource]="dataSource">
...
</table>
</div>
```
- [ ] **Step 6: Run the budget page spec**
Run:
```bash
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts --runInBand --watchman=false
```
Expected: PASS.
---
## Task 4: Visual Verification In Browser
**Files:**
- No source edits expected unless verification finds a regression.
- [ ] **Step 1: Start the dev server**
Run:
```bash
pnpm start:dev
```
If the project uses the existing Nx command instead, run:
```bash
./node_modules/.bin/nx run client:serve:development-en --hmr -o
```
Expected: Budget page is available on the local dev server.
- [ ] **Step 2: Open Budget page and Manage categories**
Use the browser to navigate to the Budget page, then click `Manage categories`.
Expected:
- Dialog width is about `42rem` on desktop.
- Form fields and Create button are aligned on one row at desktop width.
- Table actions are two compact icon buttons, not visible `edit delete` text.
- The close button remains visible and aligned to the right.
- [ ] **Step 3: Verify narrow width**
Resize the viewport below `768px`.
Expected:
- Category form stacks vertically.
- Table can scroll horizontally if needed.
- No controls overlap or clip text.
---
## Task 5: Final Verification And Commit
**Files:**
- All modified Budget UI files.
- [ ] **Step 1: Run focused tests**
Run:
```bash
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts --runInBand --watchman=false
./node_modules/.bin/jest --config apps/client/jest.config.ts apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts --runInBand --watchman=false
```
Expected: both PASS.
- [ ] **Step 2: Run Angular compiler**
Run:
```bash
./node_modules/.bin/ngc -p apps/client/tsconfig.app.json
```
Expected: exits with code 0.
- [ ] **Step 3: Run client build if the local environment allows it**
Run:
```bash
./node_modules/.bin/nx run client:build:development-en
```
Expected: PASS. If it still exits without diagnostics in this sandbox, record that `ngc` and focused specs passed, then rerun in the normal user terminal before merging.
- [ ] **Step 4: Commit**
Run:
```bash
git add apps/client/src/app/pages/portfolio/budget
git commit -m "fix: repair budget category dialog layout"
```
Expected: commit succeeds.
---
## Self-Review
- Spec coverage: The plan directly addresses the visible broken category dialog, icon text overflow, table action clipping, and responsive layout.
- Placeholder scan: No `TODO`, `TBD`, or unspecified implementation steps remain.
- Type consistency: Uses `IonIcon`, `addIcons`, `createOutline`, and `trashOutline` consistently in both Budget page and category dialog.

27
libs/common/src/lib/dtos/create-budget.dto.ts

@ -1,8 +1,27 @@
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { IsNumber, IsString, Matches, Min } from 'class-validator';
import {
IsIn,
IsNumber,
IsOptional,
IsString,
Matches,
Min
} from 'class-validator';
export const MANUAL_BUDGET_TYPES = [
'EXPENSE',
'CASH_SAVINGS',
'INVESTMENT_SAVINGS'
] as const;
export type ManualBudgetType = (typeof MANUAL_BUDGET_TYPES)[number];
export class CreateBudgetDto {
@IsOptional()
@IsString()
accountId?: string;
@IsNumber()
@Min(0)
amount: number;
@ -15,4 +34,10 @@ export class CreateBudgetDto {
@Matches(/^\d{4}-\d{2}$/)
month: string;
@IsString()
name: string;
@IsIn(MANUAL_BUDGET_TYPES)
type: ManualBudgetType;
}

5
libs/common/src/lib/dtos/index.ts

@ -5,7 +5,8 @@ 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 { CreateBudgetDto, MANUAL_BUDGET_TYPES } from './create-budget.dto';
import type { ManualBudgetType } from './create-budget.dto';
import { CreateExpenseCategoryDto } from './create-expense-category.dto';
import { CreateExpenseDto } from './create-expense.dto';
import { CreateOrderDto } from './create-order.dto';
@ -39,6 +40,8 @@ export {
CreateAssetProfileDto,
CreateAssetProfileWithMarketDataDto,
CreateBudgetDto,
MANUAL_BUDGET_TYPES,
type ManualBudgetType,
CreateExpenseCategoryDto,
CreateExpenseDto,
CreateOrderDto,

4
libs/common/src/lib/interfaces/index.ts

@ -53,7 +53,9 @@ 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 {
BudgetAccountResponse,
BudgetResponse,
BudgetType,
BudgetsResponse
} from './responses/budget-response.interface';
import type { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface';
@ -140,7 +142,9 @@ export {
BenchmarkMarketDataDetailsResponse,
BenchmarkProperty,
BenchmarkResponse,
type BudgetAccountResponse,
BudgetResponse,
type BudgetType,
BudgetsResponse,
Coupon,
CreateStripeCheckoutSessionResponse,

18
libs/common/src/lib/interfaces/responses/budget-response.interface.ts

@ -1,6 +1,20 @@
import { ExpenseCategoryResponse } from './expense-category-response.interface';
export interface BudgetAccountResponse {
id: string;
name?: string;
}
export type BudgetType =
| 'CASH_SAVINGS'
| 'EXPENSE'
| 'INVESTMENT_SAVINGS'
| 'LIABILITY_AUTOMATIC'
| 'YEARLY_EXPENSE_AUTOMATIC';
export interface BudgetResponse {
account?: BudgetAccountResponse;
accountId?: string;
amount: number;
category: ExpenseCategoryResponse;
categoryId: string;
@ -8,14 +22,18 @@ export interface BudgetResponse {
currency: string;
id: string;
month: string;
name: string;
remaining: number;
spent: number;
type: BudgetType;
updatedAt: Date;
}
export interface BudgetsResponse {
budgets: BudgetResponse[];
totalBudgeted: number;
totalMonthlySavings: number;
totalPlannedSpend: number;
totalRemaining: number;
totalSpent: number;
}

9
libs/ui/src/lib/services/data.service.spec.ts

@ -50,10 +50,13 @@ describe('DataService budget methods', () => {
it('creates a budget', () => {
const budget: CreateBudgetDto = {
accountId: 'account-1',
amount: 500,
categoryId: 'category-1',
currency: 'USD',
month: '2026-06'
month: '2026-06',
name: 'Groceries',
type: 'EXPENSE'
};
dataService.createBudget(budget).subscribe();
@ -70,7 +73,9 @@ describe('DataService budget methods', () => {
categoryId: 'category-1',
currency: 'USD',
id: 'budget-1',
month: '2026-06'
month: '2026-06',
name: 'Groceries',
type: 'EXPENSE'
};
dataService.updateBudget({ budget, id: 'budget-1' }).subscribe();

499
package-lock.json

File diff suppressed because it is too large

37
prisma/schema.prisma

@ -29,6 +29,7 @@ model Account {
activities Order[]
balance Float @default(0)
balances AccountBalance[]
budgets Budget[]
comment String?
createdAt DateTime @default(now())
currency String?
@ -117,20 +118,26 @@ model AuthDevice {
}
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
account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountId String?
accountUserId String?
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
name String @default("")
type BudgetType @default(EXPENSE)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
@@unique([categoryId, month, userId])
@@index([accountId])
@@index([categoryId])
@@index([month])
@@index([type])
@@index([userId])
}
@ -383,6 +390,14 @@ enum AssetSubClass {
STOCK
}
enum BudgetType {
CASH_SAVINGS
EXPENSE
INVESTMENT_SAVINGS
LIABILITY_AUTOMATIC
YEARLY_EXPENSE_AUTOMATIC
}
enum DataGatheringFrequency {
DAILY
HOURLY

Loading…
Cancel
Save