first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-05 13:40:27 +02:00
commit 9a6e77a5fc
89 changed files with 18276 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { Between, In } from 'typeorm';
import { AppDataSource } from '../config/data-source.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 };
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 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 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>();
for (const expense of expenses) {
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);
}
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 };
};
export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'quarter' | 'year' = 'month') => {
const repo = AppDataSource.getRepository(Expense);
const where: Record<string, unknown> = {};
if (filters.userId) where.user = { id: filters.userId };
if (filters.startDate && filters.endDate) where.expenseDate = Between(filters.startDate, filters.endDate);
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) };
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);
};
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));
};