mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
489 lines
11 KiB
489 lines
11 KiB
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
import {
|
|
CreateBudgetDto,
|
|
CreateExpenseCategoryDto,
|
|
UpdateBudgetDto,
|
|
UpdateExpenseCategoryDto
|
|
} from '@ghostfolio/common/dtos';
|
|
import {
|
|
BudgetResponse,
|
|
type BudgetType,
|
|
BudgetsResponse,
|
|
ExpenseCategoryResponse
|
|
} from '@ghostfolio/common/interfaces';
|
|
|
|
import {
|
|
ConflictException,
|
|
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({
|
|
data,
|
|
userId
|
|
}: {
|
|
data: CreateExpenseCategoryDto;
|
|
userId: string;
|
|
}): Promise<ExpenseCategoryResponse> {
|
|
await this.validateCategoryNameAvailable({
|
|
name: data.name,
|
|
userId
|
|
});
|
|
|
|
const category = await this.prismaService.expenseCategory.create({
|
|
data: {
|
|
color: data.color,
|
|
name: data.name,
|
|
user: { connect: { id: userId } }
|
|
}
|
|
});
|
|
|
|
return this.toExpenseCategoryResponse(category);
|
|
}
|
|
|
|
public async createBudget({
|
|
data,
|
|
userId
|
|
}: {
|
|
data: CreateBudgetDto;
|
|
userId: string;
|
|
}): Promise<BudgetResponse> {
|
|
await this.validateCategoryOwnership({
|
|
categoryId: data.categoryId,
|
|
userId
|
|
});
|
|
|
|
if (data.accountId) {
|
|
await this.validateAccountOwnership({
|
|
accountId: data.accountId,
|
|
userId
|
|
});
|
|
}
|
|
|
|
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: { account: true, 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 deleteCategory({ id, userId }: { id: string; userId: string }) {
|
|
await this.validateCategoryOwnership({ categoryId: id, userId });
|
|
|
|
return this.prismaService.expenseCategory.delete({
|
|
where: { id }
|
|
});
|
|
}
|
|
|
|
public async getBudget({
|
|
id,
|
|
userId
|
|
}: {
|
|
id: string;
|
|
userId: string;
|
|
}): Promise<BudgetResponse> {
|
|
const budget = await this.prismaService.budget.findFirst({
|
|
include: { account: true, 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 getCategories({
|
|
userId
|
|
}: {
|
|
userId: string;
|
|
}): Promise<ExpenseCategoryResponse[]> {
|
|
const categories = await this.prismaService.expenseCategory.findMany({
|
|
orderBy: { name: 'asc' },
|
|
where: { userId }
|
|
});
|
|
|
|
return categories.map((category) =>
|
|
this.toExpenseCategoryResponse(category)
|
|
);
|
|
}
|
|
|
|
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: { account: true, category: true },
|
|
orderBy: { name: 'asc' },
|
|
where: {
|
|
month: budgetMonth,
|
|
userId
|
|
}
|
|
});
|
|
|
|
const categoryIds = Array.from(
|
|
new Set(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) => {
|
|
const isExpenseBudget = this.isPlannedSpendBudgetType(budget.type);
|
|
|
|
return this.toBudgetResponse({
|
|
budget,
|
|
spent: isExpenseBudget
|
|
? (spentByCategoryId.get(budget.categoryId) ?? 0)
|
|
: 0
|
|
});
|
|
});
|
|
|
|
return {
|
|
budgets: budgetResponses,
|
|
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),
|
|
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
|
|
});
|
|
|
|
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,
|
|
name: data.name,
|
|
type: data.type
|
|
},
|
|
include: { account: true, category: true },
|
|
where: { id }
|
|
});
|
|
|
|
return this.toBudgetResponse({ budget, spent: 0 });
|
|
}
|
|
|
|
public async updateCategory({
|
|
data,
|
|
id,
|
|
userId
|
|
}: {
|
|
data: UpdateExpenseCategoryDto;
|
|
id: string;
|
|
userId: string;
|
|
}): Promise<ExpenseCategoryResponse> {
|
|
await this.validateCategoryOwnership({ categoryId: id, userId });
|
|
await this.validateCategoryNameAvailable({
|
|
categoryId: id,
|
|
name: data.name,
|
|
userId
|
|
});
|
|
|
|
const category = await this.prismaService.expenseCategory.update({
|
|
data: {
|
|
color: data.color,
|
|
name: data.name
|
|
},
|
|
where: { id }
|
|
});
|
|
|
|
return this.toExpenseCategoryResponse(category);
|
|
}
|
|
|
|
private formatBudgetMonth(month: Date): string {
|
|
return month.toISOString().slice(0, 7);
|
|
}
|
|
|
|
private getNextMonth(month: Date): Date {
|
|
return new Date(
|
|
Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1)
|
|
);
|
|
}
|
|
|
|
private isPlannedSpendBudgetType(type: BudgetType) {
|
|
return [
|
|
'EXPENSE',
|
|
'LIABILITY_AUTOMATIC',
|
|
'YEARLY_EXPENSE_AUTOMATIC'
|
|
].includes(type);
|
|
}
|
|
|
|
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;
|
|
account?: Account | null;
|
|
accountId?: string | null;
|
|
category: {
|
|
color?: string;
|
|
createdAt: Date;
|
|
id: string;
|
|
name: string;
|
|
updatedAt: Date;
|
|
};
|
|
categoryId: string;
|
|
createdAt: Date;
|
|
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,
|
|
createdAt: budget.createdAt,
|
|
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
|
|
};
|
|
}
|
|
|
|
private toExpenseCategoryResponse({
|
|
color,
|
|
createdAt,
|
|
id,
|
|
name,
|
|
updatedAt
|
|
}: {
|
|
color?: string;
|
|
createdAt: Date;
|
|
id: string;
|
|
name: string;
|
|
updatedAt: Date;
|
|
}): ExpenseCategoryResponse {
|
|
return { color, createdAt, id, name, updatedAt };
|
|
}
|
|
|
|
private async validateBudgetOwnership({
|
|
id,
|
|
userId
|
|
}: {
|
|
id: string;
|
|
userId: string;
|
|
}) {
|
|
const budget = await this.prismaService.budget.findFirst({
|
|
where: { id, userId }
|
|
});
|
|
|
|
if (!budget) {
|
|
throw new ForbiddenException();
|
|
}
|
|
}
|
|
|
|
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
|
|
}: {
|
|
categoryId: string;
|
|
userId: string;
|
|
}) {
|
|
const category = await this.prismaService.expenseCategory.findFirst({
|
|
where: {
|
|
id: categoryId,
|
|
userId
|
|
}
|
|
});
|
|
|
|
if (!category) {
|
|
throw new ForbiddenException();
|
|
}
|
|
}
|
|
|
|
private async validateCategoryNameAvailable({
|
|
categoryId,
|
|
name,
|
|
userId
|
|
}: {
|
|
categoryId?: string;
|
|
name: string;
|
|
userId: string;
|
|
}) {
|
|
const existingCategory = await this.prismaService.expenseCategory.findFirst(
|
|
{
|
|
where: {
|
|
name,
|
|
userId
|
|
}
|
|
}
|
|
);
|
|
|
|
if (existingCategory && existingCategory.id !== categoryId) {
|
|
throw new ConflictException();
|
|
}
|
|
}
|
|
}
|
|
|