zmiany
This commit is contained in:
108
api/src/services/recurring.service.ts
Normal file
108
api/src/services/recurring.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Expense, type ExpenseStatus } from '../entities/Expense.js';
|
||||
import { RecurringExpense, type RecurringFrequency } from '../entities/RecurringExpense.js';
|
||||
|
||||
const recurringRepo = () => AppDataSource.getRepository(RecurringExpense);
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
|
||||
const toDate = (value: string) => new Date(`${value}T00:00:00`);
|
||||
const toDateString = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const advanceDate = (value: string, frequency: RecurringFrequency, intervalValue: number) => {
|
||||
const date = toDate(value);
|
||||
if (frequency === 'WEEKLY') date.setDate(date.getDate() + intervalValue * 7);
|
||||
if (frequency === 'MONTHLY') date.setMonth(date.getMonth() + intervalValue);
|
||||
if (frequency === 'YEARLY') date.setFullYear(date.getFullYear() + intervalValue);
|
||||
return toDateString(date);
|
||||
};
|
||||
|
||||
const detectDuplicate = async (userId: string, amount: number, expenseDate: string, merchant?: string | null) => {
|
||||
const items = await expenseRepo().find({
|
||||
where: { user: { id: userId }, expenseDate },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 10
|
||||
});
|
||||
const merchantKey = merchant?.trim().toLowerCase();
|
||||
return items.some(
|
||||
(item) =>
|
||||
item.duplicateStatus !== 'DISMISSED' &&
|
||||
Math.abs(item.amount - amount) < 0.001 &&
|
||||
((merchantKey && item.merchant?.trim().toLowerCase() === merchantKey) || !merchantKey)
|
||||
);
|
||||
};
|
||||
|
||||
export const processDueRecurringExpenses = async (userId?: string) => {
|
||||
const today = toDateString(new Date());
|
||||
const rules = await recurringRepo().find({
|
||||
where: userId ? { isActive: true, user: { id: userId } } : { isActive: true },
|
||||
relations: { user: true, category: true },
|
||||
order: { nextRunDate: 'ASC' }
|
||||
});
|
||||
|
||||
for (const rule of rules) {
|
||||
let nextRun = rule.nextRunDate;
|
||||
let changed = false;
|
||||
let guard = 0;
|
||||
|
||||
while (nextRun <= today && guard < 60) {
|
||||
guard += 1;
|
||||
if (rule.endDate && nextRun > rule.endDate) {
|
||||
rule.isActive = false;
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (typeof rule.maxOccurrences === 'number' && rule.generatedCount >= rule.maxOccurrences) {
|
||||
rule.isActive = false;
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const alreadyExists = await expenseRepo().findOne({
|
||||
where: { user: { id: rule.user.id }, recurringSourceId: rule.id, expenseDate: nextRun }
|
||||
});
|
||||
|
||||
if (!alreadyExists) {
|
||||
const isDuplicate = await detectDuplicate(rule.user.id, rule.amount, nextRun, rule.merchant);
|
||||
await expenseRepo().save(
|
||||
expenseRepo().create({
|
||||
title: rule.title,
|
||||
description: rule.description,
|
||||
amount: rule.amount,
|
||||
expenseDate: nextRun,
|
||||
merchant: rule.merchant,
|
||||
paymentMethod: rule.paymentMethod,
|
||||
currency: rule.currency,
|
||||
status: (rule.defaultStatus as ExpenseStatus) ?? 'PENDING',
|
||||
tags: rule.tags ?? [],
|
||||
customFields: rule.customFields ?? {},
|
||||
possibleDuplicate: isDuplicate,
|
||||
duplicateStatus: isDuplicate ? 'OPEN' : null,
|
||||
duplicateReviewedAt: null,
|
||||
recurringSourceId: rule.id,
|
||||
user: rule.user,
|
||||
category: rule.category,
|
||||
proofs: []
|
||||
})
|
||||
);
|
||||
rule.generatedCount += 1;
|
||||
}
|
||||
|
||||
rule.lastRunDate = nextRun;
|
||||
nextRun = advanceDate(nextRun, rule.frequency, rule.intervalValue);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
rule.nextRunDate = nextRun;
|
||||
if ((rule.endDate && rule.nextRunDate > rule.endDate) || (typeof rule.maxOccurrences === 'number' && rule.generatedCount >= rule.maxOccurrences)) {
|
||||
rule.isActive = false;
|
||||
}
|
||||
await recurringRepo().save(rule);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,28 +1,84 @@
|
||||
import { Between, In } from 'typeorm';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Budget } from '../entities/Budget.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
export type StatsFilters = { userId?: string; startDate?: string; endDate?: string; categoryIds?: string[] };
|
||||
export type FlatExpense = { id: string; amount: number; expenseDate: string; categoryId: string; categoryName: string };
|
||||
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||
|
||||
export type StatsFilters = { userId?: string; startDate?: string; endDate?: string; categoryIds?: string[]; tag?: string; status?: string };
|
||||
export type FlatExpense = {
|
||||
id: string;
|
||||
amount: number;
|
||||
expenseDate: string;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
};
|
||||
|
||||
const labelMonth = (date: string) => date.slice(0, 7);
|
||||
const labelQuarter = (date: string) => { const [year, month] = date.split('-').map(Number); return `${year}-Q${Math.ceil(month / 3)}`; };
|
||||
const labelQuarter = (date: string) => {
|
||||
const [year, month] = date.split('-').map(Number);
|
||||
return `${year}-Q${Math.ceil(month / 3)}`;
|
||||
};
|
||||
const labelYear = (date: string) => date.slice(0, 4);
|
||||
export const buildBucketLabel = (date: string, bucket: 'month' | 'quarter' | 'year') => bucket === 'year' ? labelYear(date) : bucket === 'quarter' ? labelQuarter(date) : labelMonth(date);
|
||||
|
||||
export const buildBucketLabel = (date: string, bucket: 'month' | 'quarter' | 'year') =>
|
||||
bucket === 'year' ? labelYear(date) : bucket === 'quarter' ? labelQuarter(date) : labelMonth(date);
|
||||
|
||||
export const aggregateStatistics = (expenses: FlatExpense[], bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||
const total = expenses.reduce((sum, item) => sum + item.amount, 0);
|
||||
const byCategoryMap = new Map<string, { categoryId: string; categoryName: string; total: number; count: number }>();
|
||||
const timelineMap = new Map<string, number>();
|
||||
const byTagMap = new Map<string, number>();
|
||||
const byStatusMap = new Map<string, number>();
|
||||
|
||||
for (const expense of expenses) {
|
||||
const existing = byCategoryMap.get(expense.categoryId) ?? { categoryId: expense.categoryId, categoryName: expense.categoryName, total: 0, count: 0 };
|
||||
const existing = byCategoryMap.get(expense.categoryId) ?? {
|
||||
categoryId: expense.categoryId,
|
||||
categoryName: expense.categoryName,
|
||||
total: 0,
|
||||
count: 0
|
||||
};
|
||||
existing.total += expense.amount;
|
||||
existing.count += 1;
|
||||
byCategoryMap.set(expense.categoryId, existing);
|
||||
|
||||
const bucketLabel = buildBucketLabel(expense.expenseDate, bucket);
|
||||
timelineMap.set(bucketLabel, (timelineMap.get(bucketLabel) ?? 0) + expense.amount);
|
||||
|
||||
for (const tag of expense.tags ?? []) {
|
||||
byTagMap.set(tag, (byTagMap.get(tag) ?? 0) + expense.amount);
|
||||
}
|
||||
|
||||
if (expense.status) byStatusMap.set(expense.status, (byStatusMap.get(expense.status) ?? 0) + 1);
|
||||
}
|
||||
const byCategory = [...byCategoryMap.values()].sort((a, b) => b.total - a.total).map((item) => ({ ...item, total: Number(item.total.toFixed(2)) }));
|
||||
const timeline = [...timelineMap.entries()].map(([label, totalValue]) => ({ label, total: Number(totalValue.toFixed(2)) })).sort((a, b) => a.label.localeCompare(b.label));
|
||||
return { total: Number(total.toFixed(2)), count: expenses.length, average: expenses.length ? Number((total / expenses.length).toFixed(2)) : 0, byCategory, timeline, topCategory: byCategory[0] ?? null };
|
||||
|
||||
const byCategory = [...byCategoryMap.values()]
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map((item) => ({ ...item, total: Number(item.total.toFixed(2)) }));
|
||||
const timeline = [...timelineMap.entries()]
|
||||
.map(([label, totalValue]) => ({ label, total: Number(totalValue.toFixed(2)) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const byTag = [...byTagMap.entries()]
|
||||
.map(([tag, totalValue]) => ({ tag, total: Number(totalValue.toFixed(2)) }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 10);
|
||||
const byStatus = [...byStatusMap.entries()]
|
||||
.map(([status, count]) => ({ status, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return {
|
||||
total: Number(total.toFixed(2)),
|
||||
count: expenses.length,
|
||||
average: expenses.length ? Number((total / expenses.length).toFixed(2)) : 0,
|
||||
byCategory,
|
||||
timeline,
|
||||
byTag,
|
||||
byStatus,
|
||||
topCategory: byCategory[0] ?? null
|
||||
};
|
||||
};
|
||||
|
||||
export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||
const repo = AppDataSource.getRepository(Expense);
|
||||
const where: Record<string, unknown> = {};
|
||||
@@ -31,11 +87,119 @@ export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'qu
|
||||
else if (filters.startDate) where.expenseDate = Between(filters.startDate, '2999-12-31');
|
||||
else if (filters.endDate) where.expenseDate = Between('1900-01-01', filters.endDate);
|
||||
if (filters.categoryIds?.length) where.category = { id: In(filters.categoryIds) };
|
||||
if (filters.status) where.status = filters.status;
|
||||
|
||||
const expenses = await repo.find({ where, relations: { category: true }, order: { expenseDate: 'DESC' } });
|
||||
return aggregateStatistics(expenses.map((expense) => ({ id: expense.id, amount: expense.amount, expenseDate: expense.expenseDate, categoryId: expense.category.id, categoryName: expense.category.name })), bucket);
|
||||
const filteredByTag = filters.tag
|
||||
? expenses.filter((expense) => (expense.tags ?? []).some((tag) => tag.toLowerCase() === filters.tag!.toLowerCase()))
|
||||
: expenses;
|
||||
|
||||
return aggregateStatistics(
|
||||
filteredByTag.map((expense) => ({
|
||||
id: expense.id,
|
||||
amount: expense.amount,
|
||||
expenseDate: expense.expenseDate,
|
||||
categoryId: expense.category.id,
|
||||
categoryName: expense.category.name,
|
||||
tags: expense.tags ?? [],
|
||||
status: expense.status
|
||||
})),
|
||||
bucket
|
||||
);
|
||||
};
|
||||
export const detectPotentialDuplicate = async (input: { userId: string; amount: number; expenseDate: string; merchant?: string | null }) => {
|
||||
const repo = AppDataSource.getRepository(Expense);
|
||||
const candidates = await repo.find({ where: { user: { id: input.userId }, expenseDate: Between(input.expenseDate, input.expenseDate) } });
|
||||
return candidates.some((item) => Math.abs(item.amount - input.amount) < 0.001 && (input.merchant ? item.merchant?.toLowerCase() === input.merchant.toLowerCase() : true));
|
||||
|
||||
const currentMonthKey = () => {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const monthRange = (monthKey: string) => ({ startDate: `${monthKey}-01`, endDate: `${monthKey}-31` });
|
||||
|
||||
export const getCashflowSummary = async (userId: string) => {
|
||||
const expenseRepo = AppDataSource.getRepository(Expense);
|
||||
const budgetRepo = AppDataSource.getRepository(Budget);
|
||||
const recurringRepo = AppDataSource.getRepository(RecurringExpense);
|
||||
|
||||
const currentMonth = currentMonthKey();
|
||||
const currentRange = monthRange(currentMonth);
|
||||
const startWindow = new Date();
|
||||
startWindow.setMonth(startWindow.getMonth() - 5, 1);
|
||||
const startDate = `${startWindow.getFullYear()}-${`${startWindow.getMonth() + 1}`.padStart(2, '0')}-01`;
|
||||
|
||||
const expenses = await expenseRepo.find({
|
||||
where: { user: { id: userId }, expenseDate: Between(startDate, currentRange.endDate) },
|
||||
relations: { category: true },
|
||||
order: { expenseDate: 'ASC' }
|
||||
});
|
||||
const budgets = await budgetRepo.find({ where: { user: { id: userId }, month: currentMonth }, relations: { category: true } });
|
||||
const recurring = await recurringRepo.find({ where: { user: { id: userId }, isActive: true }, relations: { category: true }, order: { nextRunDate: 'ASC' } });
|
||||
|
||||
const months = new Map<string, { label: string; actual: number; budget: number }>();
|
||||
for (let offset = 5; offset >= 0; offset -= 1) {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - offset, 1);
|
||||
const key = `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
||||
months.set(key, { label: key, actual: 0, budget: 0 });
|
||||
}
|
||||
|
||||
for (const expense of expenses) {
|
||||
const month = expense.expenseDate.slice(0, 7);
|
||||
const entry = months.get(month);
|
||||
if (entry && expense.status !== 'REJECTED' && expense.status !== 'DRAFT') entry.actual += expense.amount;
|
||||
}
|
||||
|
||||
for (const budget of budgets) {
|
||||
const entry = months.get(budget.month);
|
||||
if (entry) entry.budget += budget.amount;
|
||||
}
|
||||
|
||||
const duplicateCount = expenses.filter((expense) => expense.possibleDuplicate && expense.duplicateStatus === 'OPEN' && expense.expenseDate.startsWith(currentMonth)).length;
|
||||
const currentMonthExpenses = expenses.filter((expense) => expense.expenseDate.startsWith(currentMonth));
|
||||
const actualCurrent = currentMonthExpenses
|
||||
.filter((expense) => expense.status !== 'REJECTED' && expense.status !== 'DRAFT')
|
||||
.reduce((sum, expense) => sum + expense.amount, 0);
|
||||
const pendingApproval = currentMonthExpenses.filter((expense) => expense.status === 'PENDING').length;
|
||||
const upcomingRecurring = recurring
|
||||
.filter((item) => item.nextRunDate >= currentRange.startDate && item.nextRunDate <= currentRange.endDate)
|
||||
.map((item) => ({ id: item.id, title: item.title, amount: item.amount, nextRunDate: item.nextRunDate, frequency: item.frequency }));
|
||||
|
||||
const totalBudget = budgets.reduce((sum, item) => sum + item.amount, 0);
|
||||
const budgetUsagePercent = totalBudget ? Number(((actualCurrent / totalBudget) * 100).toFixed(1)) : 0;
|
||||
const alerts = budgets
|
||||
.map((budget) => {
|
||||
const spent = currentMonthExpenses
|
||||
.filter((expense) => expense.status !== 'REJECTED' && expense.status !== 'DRAFT')
|
||||
.filter((expense) => !budget.category || expense.category.id === budget.category.id)
|
||||
.reduce((sum, expense) => sum + expense.amount, 0);
|
||||
const usagePercent = budget.amount ? Number(((spent / budget.amount) * 100).toFixed(1)) : 0;
|
||||
return { id: budget.id, name: budget.name || budget.category?.name || 'Monthly budget', usagePercent, spent: Number(spent.toFixed(2)), amount: budget.amount };
|
||||
})
|
||||
.filter((item) => item.usagePercent >= 80)
|
||||
.sort((a, b) => b.usagePercent - a.usagePercent);
|
||||
|
||||
const statusSummary = aggregateStatistics(
|
||||
currentMonthExpenses.map((expense) => ({
|
||||
id: expense.id,
|
||||
amount: expense.amount,
|
||||
expenseDate: expense.expenseDate,
|
||||
categoryId: expense.category.id,
|
||||
categoryName: expense.category.name,
|
||||
tags: expense.tags ?? [],
|
||||
status: expense.status
|
||||
}))
|
||||
).byStatus;
|
||||
|
||||
return {
|
||||
currentMonth,
|
||||
actualCurrent: Number(actualCurrent.toFixed(2)),
|
||||
totalBudget: Number(totalBudget.toFixed(2)),
|
||||
budgetUsagePercent,
|
||||
duplicateCount,
|
||||
pendingApproval,
|
||||
forecastCurrentMonth: Number((actualCurrent + upcomingRecurring.reduce((sum, item) => sum + item.amount, 0)).toFixed(2)),
|
||||
trend: [...months.values()].map((item) => ({ label: item.label, actual: Number(item.actual.toFixed(2)), budget: Number(item.budget.toFixed(2)) })),
|
||||
alerts,
|
||||
upcomingRecurring,
|
||||
statusSummary
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user