zmiany
This commit is contained in:
@@ -4,13 +4,15 @@ import path from 'node:path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { env } from './env.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { Budget } from '../entities/Budget.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { Merchant } from '../entities/Merchant.js';
|
||||
import { Proof } from '../entities/Proof.js';
|
||||
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||
import { User } from '../entities/User.js';
|
||||
|
||||
const entities = [User, Category, Expense, Proof, AppSetting, Merchant];
|
||||
const entities = [User, Category, Expense, Proof, AppSetting, Merchant, Budget, RecurringExpense];
|
||||
const baseOptions = { entities, synchronize: env.DB_SYNC, logging: env.DB_LOGGING };
|
||||
|
||||
if (env.DB_TYPE === 'sqlite') {
|
||||
|
||||
159
api/src/controllers/budget.controller.ts
Normal file
159
api/src/controllers/budget.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Budget } from '../entities/Budget.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||
|
||||
const budgetSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
name: z.string().max(120).nullable().optional(),
|
||||
amount: z.coerce.number().positive(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
alertThresholds: z.array(z.coerce.number().min(1).max(100)).default([80, 100]),
|
||||
isActive: z.boolean().default(true)
|
||||
});
|
||||
|
||||
const budgetRepo = () => AppDataSource.getRepository(Budget);
|
||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
|
||||
const serializeBudget = (item: Budget, spent: number) => {
|
||||
const amount = Number(item.amount.toFixed(2));
|
||||
const usagePercent = amount ? Number(((spent / amount) * 100).toFixed(1)) : 0;
|
||||
const alertLevel = (item.alertThresholds ?? [80, 100]).filter((threshold) => usagePercent >= threshold).sort((a, b) => b - a)[0] ?? null;
|
||||
return {
|
||||
id: item.id,
|
||||
month: item.month,
|
||||
name: item.name,
|
||||
amount,
|
||||
spent: Number(spent.toFixed(2)),
|
||||
usagePercent,
|
||||
alertLevel,
|
||||
alertThresholds: item.alertThresholds ?? [80, 100],
|
||||
isActive: item.isActive,
|
||||
category: item.category
|
||||
? {
|
||||
id: item.category.id,
|
||||
name: item.category.name,
|
||||
color: item.category.color,
|
||||
isSystem: item.category.isSystem,
|
||||
ownerId: item.category.user?.id ?? null
|
||||
}
|
||||
: null,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
};
|
||||
};
|
||||
|
||||
const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` });
|
||||
|
||||
export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const month = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
|
||||
? req.query.month
|
||||
: `${new Date().getFullYear()}-${`${new Date().getMonth() + 1}`.padStart(2, '0')}`;
|
||||
|
||||
const budgets = await budgetRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
relations: { category: { user: true }, user: true },
|
||||
order: { month: 'DESC', createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
const monthBudgets = budgets.filter((item) => item.month === month);
|
||||
const { startDate, endDate } = getMonthRange(month);
|
||||
const expenses = await expenseRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
relations: { category: true }
|
||||
});
|
||||
|
||||
const scopedExpenses = expenses.filter((item) => item.expenseDate >= startDate && item.expenseDate <= endDate && item.status !== 'REJECTED' && item.status !== 'DRAFT');
|
||||
const items = monthBudgets.map((item) => {
|
||||
const spent = scopedExpenses
|
||||
.filter((expense) => !item.category || expense.category.id === item.category.id)
|
||||
.reduce((sum, expense) => sum + expense.amount, 0);
|
||||
return serializeBudget(item, spent);
|
||||
});
|
||||
|
||||
const alerts = items.filter((item) => item.alertLevel !== null).map((item) => ({
|
||||
budgetId: item.id,
|
||||
message: `${item.name || item.category?.name || 'Monthly budget'} reached ${item.usagePercent}% of the limit.`,
|
||||
usagePercent: item.usagePercent,
|
||||
level: item.alertLevel
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
month,
|
||||
items,
|
||||
summary: {
|
||||
totalBudget: Number(items.reduce((sum, item) => sum + item.amount, 0).toFixed(2)),
|
||||
totalSpent: Number(items.reduce((sum, item) => sum + item.spent, 0).toFixed(2)),
|
||||
alerts
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = budgetSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
||||
|
||||
const category = parsed.data.categoryId
|
||||
? await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
relations: { user: true }
|
||||
})
|
||||
: null;
|
||||
|
||||
if (parsed.data.categoryId && !category) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
const saved = await budgetRepo().save(
|
||||
budgetRepo().create({
|
||||
month: parsed.data.month,
|
||||
name: parsed.data.name ?? null,
|
||||
amount: parsed.data.amount,
|
||||
alertThresholds: parsed.data.alertThresholds,
|
||||
isActive: parsed.data.isActive,
|
||||
category,
|
||||
user: { id: req.user!.id } as never
|
||||
})
|
||||
);
|
||||
|
||||
const full = await budgetRepo().findOneOrFail({ where: { id: saved.id }, relations: { category: { user: true }, user: true } });
|
||||
return res.status(201).json({ item: serializeBudget(full, 0) });
|
||||
};
|
||||
|
||||
export const updateBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = budgetSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
||||
|
||||
const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } });
|
||||
if (!item) return res.status(404).json({ message: 'Budget not found' });
|
||||
|
||||
const category = parsed.data.categoryId
|
||||
? await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
relations: { user: true }
|
||||
})
|
||||
: null;
|
||||
|
||||
if (parsed.data.categoryId && !category) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
item.month = parsed.data.month;
|
||||
item.name = parsed.data.name ?? null;
|
||||
item.amount = parsed.data.amount;
|
||||
item.alertThresholds = parsed.data.alertThresholds;
|
||||
item.isActive = parsed.data.isActive;
|
||||
item.category = category;
|
||||
|
||||
await budgetRepo().save(item);
|
||||
return res.json({ item: serializeBudget(item, 0) });
|
||||
};
|
||||
|
||||
export const deleteBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } } });
|
||||
if (!item) return res.status(404).json({ message: 'Budget not found' });
|
||||
await budgetRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
@@ -1,18 +1,21 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Response } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { Expense, type DuplicateReviewStatus, type ExpenseStatus } from '../entities/Expense.js';
|
||||
import { Proof } from '../entities/Proof.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
import { serializeProof } from '../utils/http.js';
|
||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||
|
||||
const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']).nullable().optional();
|
||||
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
|
||||
const statusSchema = z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']);
|
||||
const duplicateReviewSchema = z.object({ action: z.enum(['CONFIRM', 'DISMISS', 'REOPEN']) });
|
||||
|
||||
const createExpenseSchema = z.object({
|
||||
title: z.string().min(2).max(140),
|
||||
@@ -23,6 +26,9 @@ const createExpenseSchema = z.object({
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
paymentMethod: paymentMethodSchema,
|
||||
currency: z.string().min(3).max(8).default('PLN'),
|
||||
status: statusSchema.default('PENDING'),
|
||||
tags: z.array(z.string().min(1).max(40)).default([]),
|
||||
customFields: z.record(z.string(), z.string()).default({}),
|
||||
proofType: proofTypeSchema.optional(),
|
||||
proofLabel: z.string().max(150).nullable().optional(),
|
||||
proofNote: z.string().max(1000).nullable().optional()
|
||||
@@ -36,11 +42,14 @@ const updateExpenseSchema = z.object({
|
||||
categoryId: z.string().uuid(),
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
paymentMethod: paymentMethodSchema,
|
||||
currency: z.string().min(3).max(8).default('PLN')
|
||||
currency: z.string().min(3).max(8).default('PLN'),
|
||||
status: statusSchema.default('PENDING'),
|
||||
tags: z.array(z.string().min(1).max(40)).default([]),
|
||||
customFields: z.record(z.string(), z.string()).default({})
|
||||
});
|
||||
|
||||
const addProofSchema = z.object({
|
||||
type: proofTypeSchema,
|
||||
type: proofTypeSchema.default('OTHER'),
|
||||
label: z.string().max(150).nullable().optional(),
|
||||
note: z.string().max(1000).nullable().optional()
|
||||
});
|
||||
@@ -50,6 +59,66 @@ const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const proofRepo = () => AppDataSource.getRepository(Proof);
|
||||
|
||||
const getUploadedFiles = (req: Request) => {
|
||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||
const single = req.file ? [req.file] : [];
|
||||
return [...single, ...files].filter((file) => ['proofFile', 'proofFiles'].includes(file.fieldname));
|
||||
};
|
||||
|
||||
const removeUploadedFile = (filename?: string) => {
|
||||
if (!filename) return;
|
||||
const filePath = path.resolve(env.UPLOAD_DIR, filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
};
|
||||
|
||||
const removeUploadedFiles = (files: Express.Multer.File[]) => {
|
||||
files.forEach((file) => removeUploadedFile(file.filename));
|
||||
};
|
||||
|
||||
const normalizeTagList = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||
} catch {}
|
||||
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const normalizeCustomFields = (value: unknown) => {
|
||||
if (!value) return {} as Record<string, string>;
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>)
|
||||
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||
.filter(([key, item]) => Boolean(key && item))
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||
.filter(([key, item]) => Boolean(key && item))
|
||||
);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const enrichPayload = (body: Record<string, unknown>) => ({
|
||||
...body,
|
||||
tags: normalizeTagList(body.tags),
|
||||
customFields: normalizeCustomFields(body.customFields)
|
||||
});
|
||||
|
||||
const serializeExpense = (expense: Expense) => ({
|
||||
id: expense.id,
|
||||
title: expense.title,
|
||||
@@ -59,7 +128,13 @@ const serializeExpense = (expense: Expense) => ({
|
||||
merchant: expense.merchant,
|
||||
paymentMethod: expense.paymentMethod,
|
||||
currency: expense.currency,
|
||||
status: expense.status,
|
||||
tags: expense.tags ?? [],
|
||||
customFields: expense.customFields ?? {},
|
||||
possibleDuplicate: expense.possibleDuplicate,
|
||||
duplicateStatus: expense.duplicateStatus,
|
||||
duplicateReviewedAt: expense.duplicateReviewedAt,
|
||||
recurringSourceId: expense.recurringSourceId,
|
||||
category: {
|
||||
id: expense.category.id,
|
||||
name: expense.category.name,
|
||||
@@ -72,32 +147,87 @@ const serializeExpense = (expense: Expense) => ({
|
||||
updatedAt: expense.updatedAt
|
||||
});
|
||||
|
||||
const removeUploadedFile = (filename?: string) => {
|
||||
if (!filename) return;
|
||||
const filePath = path.resolve(env.UPLOAD_DIR, filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
};
|
||||
const normalizeInvoiceKey = (expense: Expense) =>
|
||||
Object.entries(expense.customFields ?? {}).find(([key]) => key.toLowerCase().includes('invoice'))?.[1]?.trim().toLowerCase() ?? null;
|
||||
|
||||
const isDuplicate = async (userId: string, amount: number, expenseDate: string, merchant?: string | null) => {
|
||||
const findDuplicateMatches = async (input: { userId: string; expenseId?: string; amount: number; expenseDate: string; merchant?: string | null; title?: string; customFields?: Record<string, string> }) => {
|
||||
const items = await expenseRepo().find({
|
||||
where: { user: { id: userId }, expenseDate },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 5
|
||||
where: { user: { id: input.userId } },
|
||||
relations: { category: { user: true }, proofs: true, user: true },
|
||||
order: { expenseDate: 'DESC', createdAt: 'DESC' },
|
||||
take: 200
|
||||
});
|
||||
|
||||
const merchantKey = merchant?.trim().toLowerCase();
|
||||
return items.some(
|
||||
(item) =>
|
||||
Math.abs(item.amount - amount) < 0.001 &&
|
||||
((merchantKey && item.merchant?.trim().toLowerCase() === merchantKey) || !merchantKey)
|
||||
);
|
||||
const invoiceKey = Object.entries(input.customFields ?? {}).find(([key]) => key.toLowerCase().includes('invoice'))?.[1]?.trim().toLowerCase();
|
||||
const merchantKey = input.merchant?.trim().toLowerCase();
|
||||
const inputDate = new Date(`${input.expenseDate}T00:00:00`).getTime();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (input.expenseId && item.id === input.expenseId) return false;
|
||||
if (item.duplicateStatus === 'DISMISSED') return false;
|
||||
const itemDate = new Date(`${item.expenseDate}T00:00:00`).getTime();
|
||||
const sameAmount = Math.abs(item.amount - input.amount) < 0.001;
|
||||
const sameMerchant = merchantKey ? item.merchant?.trim().toLowerCase() === merchantKey : false;
|
||||
const sameInvoice = invoiceKey ? normalizeInvoiceKey(item) === invoiceKey : false;
|
||||
const closeDate = Math.abs(itemDate - inputDate) <= 1000 * 60 * 60 * 24 * 3;
|
||||
const sameTitle = input.title ? item.title.trim().toLowerCase() === input.title.trim().toLowerCase() : false;
|
||||
return sameInvoice || (sameAmount && closeDate && (sameMerchant || sameTitle));
|
||||
});
|
||||
};
|
||||
|
||||
const buildWarnings = (duplicates: Expense[], amount: number, expenseDate: string) => {
|
||||
const warnings: string[] = [];
|
||||
if (duplicates.length) warnings.push(`Possible duplicate detected (${duplicates.length} matching expense${duplicates.length > 1 ? 's' : ''}).`);
|
||||
if (new Date(`${expenseDate}T00:00:00`).getTime() > Date.now() + 1000 * 60 * 60 * 24) warnings.push('Expense date is in the future.');
|
||||
if (amount > 50000) warnings.push('Unusually high amount. Please double-check the value.');
|
||||
return warnings;
|
||||
};
|
||||
|
||||
const hydrateExpense = (id: string) =>
|
||||
expenseRepo().findOneOrFail({
|
||||
where: { id },
|
||||
relations: { category: { user: true }, proofs: true, user: true }
|
||||
});
|
||||
|
||||
const parseFilterArray = (value: string | undefined) => value?.split(',').map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
|
||||
const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED'];
|
||||
const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
|
||||
DRAFT: ['DRAFT', 'PENDING', 'REJECTED'],
|
||||
PENDING: ['DRAFT', 'PENDING', 'APPROVED', 'REJECTED'],
|
||||
APPROVED: ['APPROVED', 'PENDING'],
|
||||
REJECTED: ['REJECTED', 'DRAFT', 'PENDING']
|
||||
};
|
||||
|
||||
const validateInitialStatus = (nextStatus: ExpenseStatus) => initialStatuses.includes(nextStatus);
|
||||
const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus);
|
||||
const approvalNeedsProof = (nextStatus: ExpenseStatus) => nextStatus === 'APPROVED';
|
||||
|
||||
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
|
||||
if (!duplicates.length) {
|
||||
expense.possibleDuplicate = false;
|
||||
expense.duplicateStatus = null;
|
||||
expense.duplicateReviewedAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
expense.possibleDuplicate = true;
|
||||
if (expense.duplicateStatus !== 'CONFIRMED') {
|
||||
expense.duplicateStatus = 'OPEN';
|
||||
expense.duplicateReviewedAt = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const listExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
|
||||
const startDate = typeof req.query.startDate === 'string' ? req.query.startDate : undefined;
|
||||
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined;
|
||||
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase() : undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase().trim() : undefined;
|
||||
const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase() : undefined;
|
||||
const tags = parseFilterArray(typeof req.query.tags === 'string' ? req.query.tags : undefined).map((item) => item.toLowerCase());
|
||||
const duplicatesOnly = String(req.query.duplicatesOnly ?? '') === 'true';
|
||||
|
||||
const items = await expenseRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
@@ -109,8 +239,17 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
|
||||
if (startDate && item.expenseDate < startDate) return false;
|
||||
if (endDate && item.expenseDate > endDate) return false;
|
||||
if (categoryId && item.category.id !== categoryId) return false;
|
||||
if (status && item.status !== status) return false;
|
||||
if (duplicatesOnly && !(item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED')) return false;
|
||||
if (tags.length) {
|
||||
const itemTags = (item.tags ?? []).map((tag) => tag.toLowerCase());
|
||||
if (!tags.every((tag) => itemTags.includes(tag))) return false;
|
||||
}
|
||||
if (search) {
|
||||
const haystack = [item.title, item.description ?? '', item.merchant ?? ''].join(' ').toLowerCase();
|
||||
const customValues = Object.entries(item.customFields ?? {}).flatMap(([key, value]) => [key, value]);
|
||||
const haystack = [item.title, item.description ?? '', item.merchant ?? '', ...(item.tags ?? []), ...customValues]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -119,13 +258,51 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
|
||||
return res.json({ items: filtered.map(serializeExpense) });
|
||||
};
|
||||
|
||||
export const listDuplicates = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const items = await expenseRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
relations: { category: { user: true }, proofs: true, user: true },
|
||||
order: { expenseDate: 'DESC', createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
const flagged = items.filter((item) => item.possibleDuplicate && item.duplicateStatus === 'OPEN');
|
||||
const groups = await Promise.all(
|
||||
flagged.map(async (item) => ({
|
||||
source: serializeExpense(item),
|
||||
matches: (await findDuplicateMatches({
|
||||
userId: req.user!.id,
|
||||
expenseId: item.id,
|
||||
amount: item.amount,
|
||||
expenseDate: item.expenseDate,
|
||||
merchant: item.merchant,
|
||||
title: item.title,
|
||||
customFields: item.customFields ?? {}
|
||||
})).slice(0, 5).map(serializeExpense)
|
||||
}))
|
||||
);
|
||||
|
||||
return res.json({ items: groups.filter((group) => group.matches.length) });
|
||||
};
|
||||
|
||||
export const createExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = createExpenseSchema.safeParse(req.body);
|
||||
const uploadedFiles = getUploadedFiles(req);
|
||||
const parsed = createExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
|
||||
if (!parsed.success) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
removeUploadedFiles(uploadedFiles);
|
||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
if (!validateInitialStatus(parsed.data.status)) {
|
||||
removeUploadedFiles(uploadedFiles);
|
||||
return res.status(400).json({ message: 'A new expense can start only as draft, pending, or approved.' });
|
||||
}
|
||||
|
||||
if (approvalNeedsProof(parsed.data.status) && uploadedFiles.length === 0) {
|
||||
removeUploadedFiles(uploadedFiles);
|
||||
return res.status(400).json({ message: 'An attachment is required before an expense can be approved.' });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
const category = await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
@@ -133,51 +310,74 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
|
||||
});
|
||||
|
||||
if (!user || !category) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
removeUploadedFiles(uploadedFiles);
|
||||
return res.status(404).json({ message: 'Category not found' });
|
||||
}
|
||||
|
||||
const proofs: Proof[] = [];
|
||||
if (req.file || parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType) {
|
||||
const duplicates = await findDuplicateMatches({
|
||||
userId: req.user!.id,
|
||||
amount: parsed.data.amount,
|
||||
expenseDate: parsed.data.expenseDate,
|
||||
merchant: parsed.data.merchant,
|
||||
title: parsed.data.title,
|
||||
customFields: parsed.data.customFields
|
||||
});
|
||||
|
||||
const proofs: Proof[] = uploadedFiles.map((file, index) =>
|
||||
proofRepo().create({
|
||||
type: parsed.data.proofType ?? 'OTHER',
|
||||
label: uploadedFiles.length === 1 ? (parsed.data.proofLabel ?? file.originalname ?? 'Attachment') : file.originalname,
|
||||
note: uploadedFiles.length === 1 && index === 0 ? (parsed.data.proofNote ?? null) : null,
|
||||
originalName: file.originalname ?? null,
|
||||
storedName: file.filename ?? null,
|
||||
mimeType: file.mimetype ?? null,
|
||||
fileSize: file.size ?? null
|
||||
})
|
||||
);
|
||||
|
||||
if (!proofs.length && (parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType)) {
|
||||
proofs.push(
|
||||
proofRepo().create({
|
||||
type: parsed.data.proofType ?? 'OTHER',
|
||||
label: parsed.data.proofLabel ?? req.file?.originalname ?? 'Attachment',
|
||||
label: parsed.data.proofLabel ?? 'Attachment',
|
||||
note: parsed.data.proofNote ?? null,
|
||||
originalName: req.file?.originalname ?? null,
|
||||
storedName: req.file?.filename ?? null,
|
||||
mimeType: req.file?.mimetype ?? null,
|
||||
fileSize: req.file?.size ?? null
|
||||
originalName: null,
|
||||
storedName: null,
|
||||
mimeType: null,
|
||||
fileSize: null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const item = await expenseRepo().save(
|
||||
expenseRepo().create({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
amount: parsed.data.amount,
|
||||
expenseDate: parsed.data.expenseDate,
|
||||
merchant: parsed.data.merchant ?? null,
|
||||
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||
currency: parsed.data.currency,
|
||||
possibleDuplicate: await isDuplicate(req.user!.id, parsed.data.amount, parsed.data.expenseDate, parsed.data.merchant),
|
||||
user,
|
||||
category,
|
||||
proofs
|
||||
})
|
||||
);
|
||||
|
||||
const fullItem = await expenseRepo().findOneOrFail({
|
||||
where: { id: item.id },
|
||||
relations: { category: { user: true }, proofs: true, user: true }
|
||||
const item = expenseRepo().create({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
amount: parsed.data.amount,
|
||||
expenseDate: parsed.data.expenseDate,
|
||||
merchant: parsed.data.merchant ?? null,
|
||||
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||
currency: parsed.data.currency,
|
||||
status: parsed.data.status,
|
||||
tags: parsed.data.tags,
|
||||
customFields: parsed.data.customFields,
|
||||
possibleDuplicate: false,
|
||||
duplicateStatus: null,
|
||||
duplicateReviewedAt: null,
|
||||
recurringSourceId: null,
|
||||
user,
|
||||
category,
|
||||
proofs
|
||||
});
|
||||
applyDuplicateState(item, duplicates);
|
||||
|
||||
return res.status(201).json({ item: serializeExpense(fullItem) });
|
||||
await expenseRepo().save(item);
|
||||
|
||||
const fullItem = await hydrateExpense(item.id);
|
||||
return res.status(201).json({ item: serializeExpense(fullItem), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
|
||||
};
|
||||
|
||||
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = updateExpenseSchema.safeParse(req.body);
|
||||
const parsed = updateExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||
}
|
||||
@@ -191,12 +391,30 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
|
||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||
}
|
||||
|
||||
if (!validateStatusTransition(item.status, parsed.data.status)) {
|
||||
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
|
||||
}
|
||||
|
||||
if (approvalNeedsProof(parsed.data.status) && item.proofs.length === 0) {
|
||||
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
|
||||
}
|
||||
|
||||
const category = await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
relations: { user: true }
|
||||
});
|
||||
if (!category) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
const duplicates = await findDuplicateMatches({
|
||||
userId: req.user!.id,
|
||||
expenseId: item.id,
|
||||
amount: parsed.data.amount,
|
||||
expenseDate: parsed.data.expenseDate,
|
||||
merchant: parsed.data.merchant,
|
||||
title: parsed.data.title,
|
||||
customFields: parsed.data.customFields
|
||||
});
|
||||
|
||||
item.title = parsed.data.title;
|
||||
item.description = parsed.data.description ?? null;
|
||||
item.amount = parsed.data.amount;
|
||||
@@ -204,10 +422,61 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
|
||||
item.merchant = parsed.data.merchant ?? null;
|
||||
item.paymentMethod = parsed.data.paymentMethod ?? null;
|
||||
item.currency = parsed.data.currency;
|
||||
item.status = parsed.data.status;
|
||||
item.tags = parsed.data.tags;
|
||||
item.customFields = parsed.data.customFields;
|
||||
item.category = category;
|
||||
applyDuplicateState(item, duplicates);
|
||||
|
||||
await expenseRepo().save(item);
|
||||
return res.json({ item: serializeExpense(item) });
|
||||
const refreshed = await hydrateExpense(item.id);
|
||||
return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
|
||||
};
|
||||
|
||||
export const reviewDuplicate = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = duplicateReviewSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid duplicate review payload', issues: parsed.error.issues });
|
||||
|
||||
const item = await expenseRepo().findOne({
|
||||
where: { id: String(req.params.id) },
|
||||
relations: { user: true, category: { user: true }, proofs: true }
|
||||
});
|
||||
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||
return res.status(403).json({ message: 'You cannot review duplicates for this expense' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (parsed.data.action === 'DISMISS') {
|
||||
item.possibleDuplicate = false;
|
||||
item.duplicateStatus = 'DISMISSED';
|
||||
item.duplicateReviewedAt = now;
|
||||
}
|
||||
|
||||
if (parsed.data.action === 'CONFIRM') {
|
||||
item.possibleDuplicate = true;
|
||||
item.duplicateStatus = 'CONFIRMED';
|
||||
item.duplicateReviewedAt = now;
|
||||
}
|
||||
|
||||
if (parsed.data.action === 'REOPEN') {
|
||||
const duplicates = await findDuplicateMatches({
|
||||
userId: item.user.id,
|
||||
expenseId: item.id,
|
||||
amount: item.amount,
|
||||
expenseDate: item.expenseDate,
|
||||
merchant: item.merchant,
|
||||
title: item.title,
|
||||
customFields: item.customFields ?? {}
|
||||
});
|
||||
item.possibleDuplicate = duplicates.length > 0;
|
||||
item.duplicateStatus = duplicates.length > 0 ? ('OPEN' as DuplicateReviewStatus) : null;
|
||||
item.duplicateReviewedAt = duplicates.length > 0 ? null : now;
|
||||
}
|
||||
|
||||
await expenseRepo().save(item);
|
||||
const refreshed = await hydrateExpense(item.id);
|
||||
return res.json({ item: serializeExpense(refreshed) });
|
||||
};
|
||||
|
||||
export const deleteExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
@@ -220,44 +489,59 @@ export const deleteExpense = async (req: AuthenticatedRequest, res: Response) =>
|
||||
return res.status(403).json({ message: 'You cannot delete this expense' });
|
||||
}
|
||||
|
||||
for (const proof of item.proofs ?? []) removeUploadedFile(proof.storedName ?? undefined);
|
||||
item.proofs.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined));
|
||||
await expenseRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = addProofSchema.safeParse(req.body);
|
||||
const uploadedFiles = getUploadedFiles(req);
|
||||
const parsed = addProofSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(400).json({ message: 'Invalid attachment payload', issues: parsed.error.issues });
|
||||
removeUploadedFiles(uploadedFiles);
|
||||
return res.status(400).json({ message: 'Invalid proof payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const item = await expenseRepo().findOne({
|
||||
const expense = await expenseRepo().findOne({
|
||||
where: { id: String(req.params.id) },
|
||||
relations: { user: true, proofs: true, category: { user: true } }
|
||||
});
|
||||
if (!item) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
if (!expense) {
|
||||
removeUploadedFiles(uploadedFiles);
|
||||
return res.status(404).json({ message: 'Expense not found' });
|
||||
}
|
||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
|
||||
removeUploadedFiles(uploadedFiles);
|
||||
return res.status(403).json({ message: 'You cannot add proof to this expense' });
|
||||
}
|
||||
|
||||
const proof = await proofRepo().save(
|
||||
proofRepo().create({
|
||||
type: parsed.data.type,
|
||||
label: parsed.data.label ?? req.file?.originalname ?? 'Attachment',
|
||||
note: parsed.data.note ?? null,
|
||||
originalName: req.file?.originalname ?? null,
|
||||
storedName: req.file?.filename ?? null,
|
||||
mimeType: req.file?.mimetype ?? null,
|
||||
fileSize: req.file?.size ?? null,
|
||||
expense: item
|
||||
})
|
||||
);
|
||||
const createdProofs = uploadedFiles.length
|
||||
? uploadedFiles.map((file) =>
|
||||
proofRepo().create({
|
||||
type: parsed.data.type,
|
||||
label: parsed.data.label ?? file.originalname ?? 'Attachment',
|
||||
note: parsed.data.note ?? null,
|
||||
originalName: file.originalname ?? null,
|
||||
storedName: file.filename ?? null,
|
||||
mimeType: file.mimetype ?? null,
|
||||
fileSize: file.size ?? null,
|
||||
expense
|
||||
})
|
||||
)
|
||||
: [
|
||||
proofRepo().create({
|
||||
type: parsed.data.type,
|
||||
label: parsed.data.label ?? 'Attachment',
|
||||
note: parsed.data.note ?? null,
|
||||
originalName: null,
|
||||
storedName: null,
|
||||
mimeType: null,
|
||||
fileSize: null,
|
||||
expense
|
||||
})
|
||||
];
|
||||
|
||||
item.proofs = [...(item.proofs ?? []), proof];
|
||||
return res.status(201).json({ proof: serializeProof(proof), expense: serializeExpense(item) });
|
||||
await proofRepo().save(createdProofs);
|
||||
const refreshed = await hydrateExpense(expense.id);
|
||||
return res.status(201).json({ proofs: createdProofs.map(serializeProof), expense: serializeExpense(refreshed) });
|
||||
};
|
||||
|
||||
505
api/src/controllers/integration.controller.ts
Normal file
505
api/src/controllers/integration.controller.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const settingsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
baseUrl: z.string().max(500).nullable().optional(),
|
||||
apiToken: z.string().max(500).optional(),
|
||||
authMode: z.enum(['bearer', 'x-api-token', 'both']).default('both'),
|
||||
ownerId: z.string().max(120).nullable().optional(),
|
||||
defaultListId: z.string().max(120).nullable().optional()
|
||||
});
|
||||
|
||||
const proxyQuerySchema = z.object({
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
list_id: z.string().optional(),
|
||||
owner_id: z.string().optional(),
|
||||
ownerId: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional()
|
||||
});
|
||||
|
||||
const importStatusSchema = z.enum(['DRAFT', 'PENDING']).default('PENDING');
|
||||
|
||||
const importListSchema = z.object({
|
||||
listId: z.union([z.string(), z.number()]),
|
||||
listTitle: z.string().max(180).nullable().optional(),
|
||||
listCreatedAt: z.string().max(60).nullable().optional(),
|
||||
categoryId: z.string().uuid(),
|
||||
status: importStatusSchema,
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
title: z.string().max(140).nullable().optional(),
|
||||
description: z.string().max(1000).nullable().optional(),
|
||||
expenseDate: z.string().min(10).max(10).nullable().optional(),
|
||||
tags: z.array(z.string().min(1).max(40)).default([])
|
||||
});
|
||||
|
||||
const importItemSchema = z.object({
|
||||
expenseId: z.union([z.string(), z.number()]).optional(),
|
||||
listId: z.union([z.string(), z.number()]).optional(),
|
||||
listTitle: z.string().max(180).nullable().optional(),
|
||||
categoryId: z.string().uuid(),
|
||||
status: importStatusSchema,
|
||||
title: z.string().min(2).max(140),
|
||||
amount: z.coerce.number().positive(),
|
||||
expenseDate: z.string().min(10).max(10),
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
ownerName: z.string().max(160).nullable().optional(),
|
||||
description: z.string().max(1000).nullable().optional(),
|
||||
tags: z.array(z.string().min(1).max(40)).default([])
|
||||
});
|
||||
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
|
||||
const normalizeBaseUrl = (value?: string | null) => (value ?? '').trim().replace(/\/+$/, '');
|
||||
const trimToNull = (value?: string | null) => {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
};
|
||||
|
||||
const normalizeTags = (values: string[]) =>
|
||||
Array.from(new Set(values.map((item) => item.trim()).filter(Boolean).slice(0, 20)));
|
||||
|
||||
const getSettings = async (userId: string) => {
|
||||
const user = await userRepo().findOne({ where: { id: userId } });
|
||||
return user ?? null;
|
||||
};
|
||||
|
||||
const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({
|
||||
enabled: Boolean(value?.enabled),
|
||||
baseUrl: value?.baseUrl ?? '',
|
||||
hasToken: Boolean(value?.apiToken),
|
||||
authMode: value?.authMode ?? 'both',
|
||||
ownerId: value?.ownerId ?? null,
|
||||
defaultListId: value?.defaultListId ?? null
|
||||
});
|
||||
|
||||
type ShoppingListConfig = NonNullable<User['shoppingListIntegration']>;
|
||||
|
||||
const buildHeaders = (config: ShoppingListConfig) => {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (config.authMode === 'bearer' || config.authMode === 'both') headers.Authorization = `Bearer ${config.apiToken}`;
|
||||
if (config.authMode === 'x-api-token' || config.authMode === 'both') headers['X-API-Token'] = String(config.apiToken);
|
||||
return headers;
|
||||
};
|
||||
|
||||
const requireConfig = async (userId: string) => {
|
||||
const user = await getSettings(userId);
|
||||
if (!user) throw new Error('User not found');
|
||||
const config = user.shoppingListIntegration;
|
||||
if (!config?.enabled || !config.baseUrl || !config.apiToken) throw new Error('Shopping list integration is not configured for this user');
|
||||
return { user, config };
|
||||
};
|
||||
|
||||
const proxyRequest = async (config: ShoppingListConfig, endpoint: string, query?: Record<string, string | number | undefined>) => {
|
||||
const url = new URL(`${normalizeBaseUrl(config.baseUrl)}${endpoint}`);
|
||||
Object.entries(query ?? {}).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}` !== '') url.searchParams.set(key, String(value));
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(config),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = response.headers.get('content-type') ?? 'application/json';
|
||||
let payload: unknown = text;
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof payload === 'object' && payload && 'message' in payload ? String((payload as { message?: unknown }).message ?? 'Integration request failed') : 'Integration request failed';
|
||||
const error = new Error(message) as Error & { status?: number; details?: unknown };
|
||||
error.status = response.status;
|
||||
error.details = payload;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const pickItems = (payload: unknown): Record<string, unknown>[] => {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const items = (payload as { items?: unknown; data?: unknown }).items ?? (payload as { items?: unknown; data?: unknown }).data;
|
||||
return Array.isArray(items) ? items.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === 'object')) : [];
|
||||
};
|
||||
|
||||
const readString = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readNumber = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const readDate = (...values: unknown[]) => {
|
||||
const raw = readString(...values);
|
||||
if (!raw) return null;
|
||||
return raw.length >= 10 ? raw.slice(0, 10) : null;
|
||||
};
|
||||
|
||||
const readItemAmount = (item: Record<string, unknown>) => readNumber(item.amount, item.total, item.value, item.price);
|
||||
const readItemDate = (item: Record<string, unknown>) => readDate(item.expense_date, item.added_at, item.created_at, item.date);
|
||||
const readItemTitle = (item: Record<string, unknown>) => {
|
||||
const list = item.list && typeof item.list === 'object' ? (item.list as Record<string, unknown>) : null;
|
||||
return readString(item.title, item.name, list?.title, list?.name) ?? 'Imported shopping list item';
|
||||
};
|
||||
const readOwnerName = (item: Record<string, unknown>) => {
|
||||
const owner = item.owner && typeof item.owner === 'object' ? (item.owner as Record<string, unknown>) : null;
|
||||
return readString(owner?.fullName, owner?.name, owner?.username, owner?.email);
|
||||
};
|
||||
|
||||
const deriveListDate = (items: Record<string, unknown>[], listCreatedAt?: string | null) => {
|
||||
const itemDates = items.map((item) => readItemDate(item)).filter((value): value is string => Boolean(value)).sort();
|
||||
return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const resolveCategory = async (userId: string, categoryId: string) =>
|
||||
categoryRepo().findOne({
|
||||
where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }],
|
||||
relations: { user: true }
|
||||
});
|
||||
|
||||
const hydrateExpense = async (id: string) =>
|
||||
expenseRepo().findOneOrFail({
|
||||
where: { id },
|
||||
relations: { user: true, category: { user: true }, proofs: true }
|
||||
});
|
||||
|
||||
const serializeExpense = (expense: Expense) => ({
|
||||
id: expense.id,
|
||||
title: expense.title,
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
expenseDate: expense.expenseDate,
|
||||
merchant: expense.merchant,
|
||||
paymentMethod: expense.paymentMethod,
|
||||
currency: expense.currency,
|
||||
status: expense.status,
|
||||
tags: expense.tags ?? [],
|
||||
customFields: expense.customFields ?? {},
|
||||
possibleDuplicate: expense.possibleDuplicate,
|
||||
duplicateStatus: expense.duplicateStatus,
|
||||
duplicateReviewedAt: expense.duplicateReviewedAt,
|
||||
recurringSourceId: expense.recurringSourceId,
|
||||
category: {
|
||||
id: expense.category.id,
|
||||
name: expense.category.name,
|
||||
color: expense.category.color,
|
||||
isSystem: expense.category.isSystem,
|
||||
ownerId: expense.category.user?.id ?? null
|
||||
},
|
||||
proofs: expense.proofs ?? [],
|
||||
createdAt: expense.createdAt,
|
||||
updatedAt: expense.updatedAt
|
||||
});
|
||||
|
||||
const getExistingExpenses = async (userId: string) =>
|
||||
expenseRepo().find({
|
||||
where: { user: { id: userId } },
|
||||
relations: { user: true, category: { user: true }, proofs: true },
|
||||
order: { expenseDate: 'DESC', createdAt: 'DESC' },
|
||||
take: 500
|
||||
});
|
||||
|
||||
const hasExternalImport = (items: Expense[], key: string, value?: string | null) => {
|
||||
if (!value) return false;
|
||||
return items.some((item) => String(item.customFields?.[key] ?? '') === String(value));
|
||||
};
|
||||
|
||||
const findDuplicateMatches = (items: Expense[], input: { amount: number; expenseDate: string; title: string; merchant?: string | null; externalExpenseId?: string | null; externalListId?: string | null }) => {
|
||||
const merchantKey = input.merchant?.trim().toLowerCase() ?? null;
|
||||
const titleKey = input.title.trim().toLowerCase();
|
||||
const inputDate = new Date(`${input.expenseDate}T00:00:00`).getTime();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (item.duplicateStatus === 'DISMISSED') return false;
|
||||
if (input.externalExpenseId && String(item.customFields?.externalShoppingListExpenseId ?? '') === input.externalExpenseId) return true;
|
||||
if (input.externalListId && String(item.customFields?.externalShoppingListListId ?? '') === input.externalListId) return true;
|
||||
const itemDate = new Date(`${item.expenseDate}T00:00:00`).getTime();
|
||||
const sameAmount = Math.abs(item.amount - input.amount) < 0.001;
|
||||
const sameMerchant = merchantKey ? item.merchant?.trim().toLowerCase() === merchantKey : false;
|
||||
const sameTitle = item.title.trim().toLowerCase() === titleKey;
|
||||
const closeDate = Math.abs(itemDate - inputDate) <= 1000 * 60 * 60 * 24 * 3;
|
||||
return sameAmount && closeDate && (sameMerchant || sameTitle);
|
||||
});
|
||||
};
|
||||
|
||||
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
|
||||
expense.possibleDuplicate = duplicates.length > 0;
|
||||
expense.duplicateStatus = duplicates.length > 0 ? 'OPEN' : null;
|
||||
expense.duplicateReviewedAt = null;
|
||||
};
|
||||
|
||||
const createImportedExpense = async (input: {
|
||||
userId: string;
|
||||
categoryId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
amount: number;
|
||||
expenseDate: string;
|
||||
merchant?: string | null;
|
||||
status: 'DRAFT' | 'PENDING';
|
||||
tags: string[];
|
||||
customFields: Record<string, string>;
|
||||
}) => {
|
||||
const user = await userRepo().findOne({ where: { id: input.userId } });
|
||||
const category = await resolveCategory(input.userId, input.categoryId);
|
||||
if (!user || !category) throw new Error('Category not found');
|
||||
|
||||
const existing = await getExistingExpenses(input.userId);
|
||||
const duplicates = findDuplicateMatches(existing, {
|
||||
amount: input.amount,
|
||||
expenseDate: input.expenseDate,
|
||||
title: input.title,
|
||||
merchant: input.merchant,
|
||||
externalExpenseId: input.customFields.externalShoppingListExpenseId ?? null,
|
||||
externalListId: input.customFields.externalShoppingListImportType === 'LIST' ? input.customFields.externalShoppingListListId ?? null : null
|
||||
});
|
||||
|
||||
const expense = expenseRepo().create({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
amount: input.amount,
|
||||
expenseDate: input.expenseDate,
|
||||
merchant: input.merchant ?? null,
|
||||
paymentMethod: null,
|
||||
currency: 'PLN',
|
||||
status: input.status,
|
||||
tags: input.tags,
|
||||
customFields: input.customFields,
|
||||
possibleDuplicate: false,
|
||||
duplicateStatus: null,
|
||||
duplicateReviewedAt: null,
|
||||
recurringSourceId: null,
|
||||
user,
|
||||
category,
|
||||
proofs: []
|
||||
});
|
||||
|
||||
applyDuplicateState(expense, duplicates);
|
||||
await expenseRepo().save(expense);
|
||||
const hydrated = await hydrateExpense(expense.id);
|
||||
const warnings = duplicates.length ? [`Possible duplicate detected (${duplicates.length} matching expense${duplicates.length > 1 ? 's' : ''}).`] : [];
|
||||
return { item: serializeExpense(hydrated), warnings };
|
||||
};
|
||||
|
||||
export const getShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await getSettings(req.user!.id);
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
|
||||
};
|
||||
|
||||
export const updateShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = settingsSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration settings payload', issues: parsed.error.issues });
|
||||
|
||||
const user = await getSettings(req.user!.id);
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
const current = user.shoppingListIntegration ?? {};
|
||||
user.shoppingListIntegration = {
|
||||
enabled: parsed.data.enabled,
|
||||
baseUrl: normalizeBaseUrl(parsed.data.baseUrl),
|
||||
apiToken: parsed.data.apiToken?.trim() ? parsed.data.apiToken.trim() : current.apiToken,
|
||||
authMode: parsed.data.authMode,
|
||||
ownerId: parsed.data.ownerId ?? null,
|
||||
defaultListId: parsed.data.defaultListId ?? null
|
||||
};
|
||||
|
||||
await userRepo().save(user);
|
||||
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
|
||||
};
|
||||
|
||||
export const testShoppingListConnection = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { config } = await requireConfig(req.user!.id);
|
||||
const payload = await proxyRequest(config, '/api/ping');
|
||||
return res.json({ ok: true, payload });
|
||||
} catch (error) {
|
||||
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getShoppingListSummary = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = proxyQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||
try {
|
||||
const { config } = await requireConfig(req.user!.id);
|
||||
const payload = await proxyRequest(config, '/api/expenses/summary', {
|
||||
start_date: parsed.data.start_date,
|
||||
end_date: parsed.data.end_date,
|
||||
list_id: parsed.data.list_id ?? config.defaultListId ?? undefined,
|
||||
owner_id: parsed.data.owner_id ?? parsed.data.ownerId ?? config.ownerId ?? undefined
|
||||
});
|
||||
return res.json(payload);
|
||||
} catch (error) {
|
||||
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getShoppingListLatestExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = proxyQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||
try {
|
||||
const { config } = await requireConfig(req.user!.id);
|
||||
const payload = await proxyRequest(config, '/api/expenses/latest', {
|
||||
start_date: parsed.data.start_date,
|
||||
end_date: parsed.data.end_date,
|
||||
list_id: parsed.data.list_id ?? config.defaultListId ?? undefined,
|
||||
owner_id: parsed.data.owner_id ?? parsed.data.ownerId ?? config.ownerId ?? undefined,
|
||||
limit: parsed.data.limit
|
||||
});
|
||||
return res.json(payload);
|
||||
} catch (error) {
|
||||
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getShoppingLists = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = proxyQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||
try {
|
||||
const { config } = await requireConfig(req.user!.id);
|
||||
const payload = await proxyRequest(config, '/api/lists', {
|
||||
owner_id: parsed.data.owner_id ?? parsed.data.ownerId ?? config.ownerId ?? undefined,
|
||||
limit: parsed.data.limit
|
||||
});
|
||||
return res.json(payload);
|
||||
} catch (error) {
|
||||
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getShoppingListExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = proxyQuerySchema.pick({ limit: true }).safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid integration query', issues: parsed.error.issues });
|
||||
try {
|
||||
const { config } = await requireConfig(req.user!.id);
|
||||
const listId = String(req.params.id);
|
||||
const payload = await proxyRequest(config, `/api/lists/${encodeURIComponent(listId)}/expenses`, { limit: parsed.data.limit });
|
||||
return res.json(payload);
|
||||
} catch (error) {
|
||||
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = importListSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list import payload', issues: parsed.error.issues });
|
||||
|
||||
try {
|
||||
const { user, config } = await requireConfig(req.user!.id);
|
||||
const existing = await getExistingExpenses(user.id);
|
||||
const listId = String(parsed.data.listId);
|
||||
if (hasExternalImport(existing, 'externalShoppingListListId', listId)) {
|
||||
return res.status(409).json({ message: 'This shopping list has already been imported as a local expense.' });
|
||||
}
|
||||
|
||||
const payload = await proxyRequest(config, `/api/lists/${encodeURIComponent(listId)}/expenses`, { limit: 500 });
|
||||
const items = pickItems(payload);
|
||||
const totalAmount = items.reduce((sum, item) => sum + readItemAmount(item), 0);
|
||||
if (!items.length || totalAmount <= 0) {
|
||||
return res.status(400).json({ message: 'The selected shopping list does not contain any importable expenses.' });
|
||||
}
|
||||
|
||||
const ownerNames = Array.from(new Set(items.map((item) => readOwnerName(item)).filter((value): value is string => Boolean(value))));
|
||||
const derivedDate = parsed.data.expenseDate ?? deriveListDate(items, parsed.data.listCreatedAt);
|
||||
const title = trimToNull(parsed.data.title) ?? `Shopping list: ${trimToNull(parsed.data.listTitle) ?? listId}`;
|
||||
const description = trimToNull(parsed.data.description) ?? `Imported aggregate from shopping list API (${items.length} item${items.length > 1 ? 's' : ''}).`;
|
||||
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
||||
const tags = normalizeTags([...parsed.data.tags, 'shopping-list', 'external-import']);
|
||||
|
||||
const result = await createImportedExpense({
|
||||
userId: user.id,
|
||||
categoryId: parsed.data.categoryId,
|
||||
title,
|
||||
description,
|
||||
amount: Number(totalAmount.toFixed(2)),
|
||||
expenseDate: derivedDate,
|
||||
merchant,
|
||||
status: parsed.data.status,
|
||||
tags,
|
||||
customFields: {
|
||||
externalSource: 'shopping-list-api',
|
||||
externalShoppingListImportType: 'LIST',
|
||||
externalShoppingListListId: listId,
|
||||
externalShoppingListListTitle: trimToNull(parsed.data.listTitle) ?? listId,
|
||||
externalShoppingListItemCount: String(items.length),
|
||||
externalShoppingListOwner: ownerNames.join(', ')
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = importItemSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list item import payload', issues: parsed.error.issues });
|
||||
|
||||
try {
|
||||
const { user } = await requireConfig(req.user!.id);
|
||||
const externalExpenseId = parsed.data.expenseId !== undefined ? String(parsed.data.expenseId) : null;
|
||||
const existing = await getExistingExpenses(user.id);
|
||||
if (externalExpenseId && hasExternalImport(existing, 'externalShoppingListExpenseId', externalExpenseId)) {
|
||||
return res.status(409).json({ message: 'This shopping list item has already been imported as a local expense.' });
|
||||
}
|
||||
|
||||
const title = parsed.data.title.trim();
|
||||
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
||||
const tags = normalizeTags([...parsed.data.tags, 'shopping-list', 'external-import']);
|
||||
const description = trimToNull(parsed.data.description) ?? `Imported from shopping list API${parsed.data.listTitle ? ` (${parsed.data.listTitle})` : ''}.`;
|
||||
|
||||
const result = await createImportedExpense({
|
||||
userId: user.id,
|
||||
categoryId: parsed.data.categoryId,
|
||||
title,
|
||||
description,
|
||||
amount: Number(parsed.data.amount.toFixed(2)),
|
||||
expenseDate: parsed.data.expenseDate,
|
||||
merchant,
|
||||
status: parsed.data.status,
|
||||
tags,
|
||||
customFields: {
|
||||
externalSource: 'shopping-list-api',
|
||||
externalShoppingListImportType: 'ITEM',
|
||||
externalShoppingListExpenseId: externalExpenseId ?? '',
|
||||
externalShoppingListListId: parsed.data.listId !== undefined ? String(parsed.data.listId) : '',
|
||||
externalShoppingListListTitle: trimToNull(parsed.data.listTitle) ?? '',
|
||||
externalShoppingListOwner: trimToNull(parsed.data.ownerName) ?? ''
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
190
api/src/controllers/recurring.controller.ts
Normal file
190
api/src/controllers/recurring.controller.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||
|
||||
const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']).nullable().optional();
|
||||
const recurringSchema = z.object({
|
||||
title: z.string().min(2).max(140),
|
||||
description: z.string().max(1000).nullable().optional(),
|
||||
amount: z.coerce.number().positive(),
|
||||
categoryId: z.string().uuid(),
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
paymentMethod: paymentMethodSchema,
|
||||
currency: z.string().min(3).max(8).default('PLN'),
|
||||
frequency: z.enum(['WEEKLY', 'MONTHLY', 'YEARLY']).default('MONTHLY'),
|
||||
intervalValue: z.coerce.number().int().min(1).max(24).default(1),
|
||||
startDate: z.string().min(10).max(10),
|
||||
nextRunDate: z.string().min(10).max(10),
|
||||
endDate: z.string().min(10).max(10).nullable().optional(),
|
||||
maxOccurrences: z.coerce.number().int().min(1).max(500).nullable().optional(),
|
||||
defaultStatus: z.enum(['DRAFT', 'PENDING']).default('PENDING'),
|
||||
tags: z.array(z.string().min(1).max(40)).default([]),
|
||||
customFields: z.record(z.string(), z.string()).default({}),
|
||||
isActive: z.boolean().default(true)
|
||||
});
|
||||
|
||||
const recurringRepo = () => AppDataSource.getRepository(RecurringExpense);
|
||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
|
||||
const normalizeTagList = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||
} catch {}
|
||||
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const normalizeCustomFields = (value: unknown) => {
|
||||
if (!value) return {} as Record<string, string>;
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>)
|
||||
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||
.filter(([key, item]) => Boolean(key && item))
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, item]) => [String(key).trim(), String(item ?? '').trim()] as [string, string])
|
||||
.filter(([key, item]) => Boolean(key && item))
|
||||
);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const serializeRecurring = (item: RecurringExpense) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
merchant: item.merchant,
|
||||
paymentMethod: item.paymentMethod,
|
||||
currency: item.currency,
|
||||
frequency: item.frequency,
|
||||
intervalValue: item.intervalValue,
|
||||
startDate: item.startDate,
|
||||
nextRunDate: item.nextRunDate,
|
||||
lastRunDate: item.lastRunDate,
|
||||
endDate: item.endDate,
|
||||
maxOccurrences: item.maxOccurrences,
|
||||
generatedCount: item.generatedCount,
|
||||
defaultStatus: item.defaultStatus,
|
||||
tags: item.tags ?? [],
|
||||
customFields: item.customFields ?? {},
|
||||
isActive: item.isActive,
|
||||
category: {
|
||||
id: item.category.id,
|
||||
name: item.category.name,
|
||||
color: item.category.color,
|
||||
isSystem: item.category.isSystem,
|
||||
ownerId: item.category.user?.id ?? null
|
||||
},
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
});
|
||||
|
||||
const validateSchedule = (data: z.infer<typeof recurringSchema>) => {
|
||||
if (data.endDate && data.endDate < data.startDate) return 'End date cannot be earlier than start date.';
|
||||
if (data.endDate && data.nextRunDate > data.endDate) return 'Next run date cannot be later than the end date.';
|
||||
if (data.nextRunDate < data.startDate) return 'Next run date cannot be earlier than the start date.';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const listRecurringExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const items = await recurringRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
relations: { category: { user: true }, user: true },
|
||||
order: { nextRunDate: 'ASC', createdAt: 'DESC' }
|
||||
});
|
||||
return res.json({ items: items.map(serializeRecurring) });
|
||||
};
|
||||
|
||||
export const createRecurringExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = recurringSchema.safeParse({ ...req.body, tags: normalizeTagList(req.body.tags), customFields: normalizeCustomFields(req.body.customFields) });
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid recurring expense payload', issues: parsed.error.issues });
|
||||
|
||||
const scheduleError = validateSchedule(parsed.data);
|
||||
if (scheduleError) return res.status(400).json({ message: scheduleError });
|
||||
|
||||
const category = await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
relations: { user: true }
|
||||
});
|
||||
if (!category) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
const saved = await recurringRepo().save(
|
||||
recurringRepo().create({
|
||||
...parsed.data,
|
||||
description: parsed.data.description ?? null,
|
||||
merchant: parsed.data.merchant ?? null,
|
||||
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||
endDate: parsed.data.endDate ?? null,
|
||||
maxOccurrences: parsed.data.maxOccurrences ?? null,
|
||||
generatedCount: 0,
|
||||
category,
|
||||
user: { id: req.user!.id } as never
|
||||
})
|
||||
);
|
||||
|
||||
const full = await recurringRepo().findOneOrFail({ where: { id: saved.id }, relations: { category: { user: true }, user: true } });
|
||||
return res.status(201).json({ item: serializeRecurring(full) });
|
||||
};
|
||||
|
||||
export const updateRecurringExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = recurringSchema.safeParse({ ...req.body, tags: normalizeTagList(req.body.tags), customFields: normalizeCustomFields(req.body.customFields) });
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid recurring expense payload', issues: parsed.error.issues });
|
||||
|
||||
const scheduleError = validateSchedule(parsed.data);
|
||||
if (scheduleError) return res.status(400).json({ message: scheduleError });
|
||||
|
||||
const item = await recurringRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } });
|
||||
if (!item) return res.status(404).json({ message: 'Recurring expense not found' });
|
||||
|
||||
const category = await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
relations: { user: true }
|
||||
});
|
||||
if (!category) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
Object.assign(item, {
|
||||
...parsed.data,
|
||||
description: parsed.data.description ?? null,
|
||||
merchant: parsed.data.merchant ?? null,
|
||||
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||
endDate: parsed.data.endDate ?? null,
|
||||
maxOccurrences: parsed.data.maxOccurrences ?? null,
|
||||
category
|
||||
});
|
||||
|
||||
await recurringRepo().save(item);
|
||||
return res.json({ item: serializeRecurring(item) });
|
||||
};
|
||||
|
||||
export const deleteRecurringExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await recurringRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } } });
|
||||
if (!item) return res.status(404).json({ message: 'Recurring expense not found' });
|
||||
await recurringRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
export const processRecurringNow = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
return res.json({ message: 'Recurring expenses processed successfully' });
|
||||
};
|
||||
@@ -1,25 +1,36 @@
|
||||
import type { Response } from 'express';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
frequency: z.enum(['monthly', 'yearly', 'threshold']),
|
||||
thresholdAmount: z.number().min(0).default(0),
|
||||
sendToEmail: z.email().nullable().optional(),
|
||||
sendToEmail: z.string().email().nullable().optional(),
|
||||
categoryIds: z.array(z.string().uuid()).default([])
|
||||
});
|
||||
|
||||
const previewOverrideSchema = preferencesSchema.partial();
|
||||
const exportQuerySchema = z.object({
|
||||
format: z.enum(['csv', 'json', 'html', 'pdf']).default('csv'),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
categoryIds: z.string().optional(),
|
||||
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']).optional(),
|
||||
tag: z.string().optional()
|
||||
});
|
||||
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const require = createRequire(import.meta.url);
|
||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
|
||||
const defaultPrefs = (email: string) => ({
|
||||
enabled: false,
|
||||
@@ -59,6 +70,11 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
|
||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.label}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
const tagRows = (summary.byTag ?? [])
|
||||
.slice(0, 6)
|
||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.tag}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;color:#111827;max-width:760px;margin:0 auto">
|
||||
<h1 style="margin-bottom:8px">${title}</h1>
|
||||
@@ -67,10 +83,112 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
|
||||
<table style="width:100%;border-collapse:collapse">${categoryRows || '<tr><td>No data</td></tr>'}</table>
|
||||
<h2 style="margin-top:32px">Timeline</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${timelineRows || '<tr><td>No data</td></tr>'}</table>
|
||||
<h2 style="margin-top:32px">Top tags</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${tagRows || '<tr><td>No data</td></tr>'}</table>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const collectExportItems = async (userId: string, filters: { startDate?: string; endDate?: string; categoryIds?: string[]; status?: string; tag?: string }) => {
|
||||
const items = await expenseRepo().find({ where: { user: { id: userId } }, relations: { category: true, proofs: true }, order: { expenseDate: 'DESC', createdAt: 'DESC' } });
|
||||
return items.filter((item) => {
|
||||
if (filters.startDate && item.expenseDate < filters.startDate) return false;
|
||||
if (filters.endDate && item.expenseDate > filters.endDate) return false;
|
||||
if (filters.categoryIds?.length && !filters.categoryIds.includes(item.category.id)) return false;
|
||||
if (filters.status && item.status !== filters.status) return false;
|
||||
if (filters.tag && !(item.tags ?? []).some((tag) => tag.toLowerCase() === filters.tag!.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const escapeCsv = (value: unknown) => {
|
||||
const text = String(value ?? '');
|
||||
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
||||
return text;
|
||||
};
|
||||
|
||||
const escapePdfText = (value: string) => value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)').replace(/\r/g, '').replace(/\n/g, ' ');
|
||||
|
||||
const buildPdfBuffer = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>, items: Expense[]) => {
|
||||
const lines = [
|
||||
title,
|
||||
`Generated: ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`,
|
||||
`Total: ${summary.total.toFixed(2)} | Count: ${summary.count} | Average: ${summary.average.toFixed(2)}`,
|
||||
'',
|
||||
'Top categories:'
|
||||
];
|
||||
|
||||
if (summary.byCategory.length) {
|
||||
summary.byCategory.slice(0, 8).forEach((item) => lines.push(`- ${item.categoryName}: ${item.total.toFixed(2)} (${item.count})`));
|
||||
} else {
|
||||
lines.push('- No data');
|
||||
}
|
||||
|
||||
lines.push('', 'Timeline:');
|
||||
if (summary.timeline.length) {
|
||||
summary.timeline.slice(-8).forEach((item) => lines.push(`- ${item.label}: ${item.total.toFixed(2)}`));
|
||||
} else {
|
||||
lines.push('- No data');
|
||||
}
|
||||
|
||||
lines.push('', 'Expenses:');
|
||||
if (items.length) {
|
||||
items.slice(0, 30).forEach((item) => lines.push(`- ${item.expenseDate} | ${item.title} | ${item.category.name} | ${item.amount.toFixed(2)} ${item.currency} | ${item.status}`));
|
||||
} else {
|
||||
lines.push('- No data');
|
||||
}
|
||||
|
||||
const pageHeight = 792;
|
||||
const lineHeight = 16;
|
||||
const startY = 750;
|
||||
const linesPerPage = 40;
|
||||
const chunks = [] as string[][];
|
||||
for (let index = 0; index < lines.length; index += linesPerPage) chunks.push(lines.slice(index, index + linesPerPage));
|
||||
|
||||
const objects: string[] = [];
|
||||
const kids: string[] = [];
|
||||
const fontObjectNumber = 3 + chunks.length * 2;
|
||||
|
||||
objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||
|
||||
chunks.forEach((chunk, pageIndex) => {
|
||||
const pageObjectNumber = 3 + pageIndex * 2;
|
||||
const contentObjectNumber = pageObjectNumber + 1;
|
||||
kids.push(`${pageObjectNumber} 0 R`);
|
||||
|
||||
const contentLines = ['BT', '/F1 12 Tf', `50 ${startY} Td`];
|
||||
chunk.forEach((line, index) => {
|
||||
if (index > 0) contentLines.push(`0 -${lineHeight} Td`);
|
||||
contentLines.push(`(${escapePdfText(line)}) Tj`);
|
||||
});
|
||||
contentLines.push('ET');
|
||||
const stream = contentLines.join('\n');
|
||||
|
||||
objects[pageObjectNumber] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${pageHeight}] /Resources << /Font << /F1 ${fontObjectNumber} 0 R >> >> /Contents ${contentObjectNumber} 0 R >>`;
|
||||
objects[contentObjectNumber] = `<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`;
|
||||
});
|
||||
|
||||
objects[2] = `<< /Type /Pages /Count ${kids.length} /Kids [${kids.join(' ')}] >>`;
|
||||
objects[fontObjectNumber] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
|
||||
|
||||
let pdf = '%PDF-1.4\n';
|
||||
const offsets: number[] = [0];
|
||||
for (let index = 1; index < objects.length; index += 1) {
|
||||
if (!objects[index]) continue;
|
||||
offsets[index] = Buffer.byteLength(pdf, 'utf8');
|
||||
pdf += `${index} 0 obj\n${objects[index]}\nendobj\n`;
|
||||
}
|
||||
|
||||
const xrefOffset = Buffer.byteLength(pdf, 'utf8');
|
||||
pdf += `xref\n0 ${objects.length}\n`;
|
||||
pdf += '0000000000 65535 f \n';
|
||||
for (let index = 1; index < objects.length; index += 1) {
|
||||
pdf += `${String(offsets[index] ?? 0).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||
return Buffer.from(pdf, 'utf8');
|
||||
};
|
||||
|
||||
export const getPreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
@@ -92,6 +210,7 @@ export const updatePreferences = async (req: AuthenticatedRequest, res: Response
|
||||
};
|
||||
|
||||
export const previewReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
@@ -120,6 +239,7 @@ export const previewReport = async (req: AuthenticatedRequest, res: Response) =>
|
||||
};
|
||||
|
||||
export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
@@ -151,9 +271,7 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
const to = prefs.sendToEmail || user.email;
|
||||
await transport.sendMail({
|
||||
from: appSettings.smtpFromName
|
||||
? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>`
|
||||
: appSettings.smtpFromEmail,
|
||||
from: appSettings.smtpFromName ? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>` : appSettings.smtpFromEmail,
|
||||
to,
|
||||
subject: `${appSettings.appName} - ${range.label} report`,
|
||||
html: buildReportHtml(`${range.label} report`, summary)
|
||||
@@ -161,3 +279,92 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
return res.json({ message: 'Report email was sent', sentTo: to });
|
||||
};
|
||||
|
||||
export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const parsed = exportQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues });
|
||||
|
||||
const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? [];
|
||||
const items = await collectExportItems(req.user!.id, {
|
||||
startDate: parsed.data.startDate,
|
||||
endDate: parsed.data.endDate,
|
||||
categoryIds,
|
||||
status: parsed.data.status,
|
||||
tag: parsed.data.tag
|
||||
});
|
||||
const summary = await getStatistics(
|
||||
{
|
||||
userId: req.user!.id,
|
||||
startDate: parsed.data.startDate,
|
||||
endDate: parsed.data.endDate,
|
||||
categoryIds,
|
||||
status: parsed.data.status,
|
||||
tag: parsed.data.tag
|
||||
},
|
||||
'month'
|
||||
);
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
if (parsed.data.format === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.json"`);
|
||||
return res.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
filters: parsed.data,
|
||||
summary,
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
date: item.expenseDate,
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
status: item.status,
|
||||
category: item.category.name,
|
||||
merchant: item.merchant,
|
||||
tags: item.tags ?? [],
|
||||
customFields: item.customFields ?? {},
|
||||
attachments: item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment')
|
||||
}))
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.format === 'html') {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.html"`);
|
||||
return res.send(buildReportHtml('Expense export', summary));
|
||||
}
|
||||
|
||||
if (parsed.data.format === 'pdf') {
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.pdf"`);
|
||||
return res.send(buildPdfBuffer('Expense export', summary, items));
|
||||
}
|
||||
|
||||
const rows = [
|
||||
['Date', 'Title', 'Category', 'Merchant', 'Amount', 'Currency', 'Status', 'Tags', 'Custom fields', 'Attachments'],
|
||||
...items.map((item) => [
|
||||
item.expenseDate,
|
||||
item.title,
|
||||
item.category.name,
|
||||
item.merchant ?? '',
|
||||
item.amount.toFixed(2),
|
||||
item.currency,
|
||||
item.status,
|
||||
(item.tags ?? []).join('|'),
|
||||
Object.entries(item.customFields ?? {})
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(' | '),
|
||||
item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment').join(' | ')
|
||||
])
|
||||
];
|
||||
const csv = rows.map((row) => row.map(escapeCsv).join(',')).join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.csv"`);
|
||||
return res.send(csv);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import { getCashflowSummary, getStatistics } from '../services/statistics.service.js';
|
||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const querySchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
categoryIds: z.string().optional(),
|
||||
bucket: z.enum(['month', 'quarter', 'year']).optional()
|
||||
bucket: z.enum(['month', 'quarter', 'year']).optional(),
|
||||
tag: z.string().optional(),
|
||||
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']).optional()
|
||||
});
|
||||
|
||||
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
|
||||
@@ -23,9 +27,16 @@ export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
||||
userId: req.user!.id,
|
||||
startDate: parsed.data.startDate,
|
||||
endDate: parsed.data.endDate,
|
||||
categoryIds
|
||||
categoryIds,
|
||||
tag: parsed.data.tag,
|
||||
status: parsed.data.status
|
||||
},
|
||||
parsed.data.bucket ?? 'month'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const getCashflow = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
return res.json(await getCashflowSummary(req.user!.id));
|
||||
};
|
||||
|
||||
37
api/src/entities/Budget.ts
Normal file
37
api/src/entities/Budget.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Category } from './Category.js';
|
||||
import { User } from './User.js';
|
||||
import { decimalTransformer } from '../utils/decimal.js';
|
||||
|
||||
@Entity('budgets')
|
||||
export class Budget {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 7 })
|
||||
month!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, transformer: decimalTransformer })
|
||||
amount!: number;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
alertThresholds!: number[] | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Category, { eager: true, nullable: true, onDelete: 'SET NULL' })
|
||||
category!: Category | null;
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { Proof } from './Proof.js';
|
||||
import { User } from './User.js';
|
||||
import { decimalTransformer } from '../utils/decimal.js';
|
||||
|
||||
export type ExpenseStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
export type DuplicateReviewStatus = 'OPEN' | 'CONFIRMED' | 'DISMISSED';
|
||||
|
||||
@Entity('expenses')
|
||||
export class Expense {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -30,9 +33,27 @@ export class Expense {
|
||||
@Column({ type: 'varchar', length: 12, default: 'PLN' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ExpenseStatus;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
tags!: string[] | null;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
customFields!: Record<string, string> | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
possibleDuplicate!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
duplicateStatus!: DuplicateReviewStatus | null;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
duplicateReviewedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 36, nullable: true })
|
||||
recurringSourceId!: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
78
api/src/entities/RecurringExpense.ts
Normal file
78
api/src/entities/RecurringExpense.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Category } from './Category.js';
|
||||
import { User } from './User.js';
|
||||
import { decimalTransformer } from '../utils/decimal.js';
|
||||
|
||||
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
||||
|
||||
@Entity('recurring_expenses')
|
||||
export class RecurringExpense {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 140 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, transformer: decimalTransformer })
|
||||
amount!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||
merchant!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
paymentMethod!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 12, default: 'PLN' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'MONTHLY' })
|
||||
frequency!: RecurringFrequency;
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
intervalValue!: number;
|
||||
|
||||
@Column({ type: 'date' })
|
||||
startDate!: string;
|
||||
|
||||
@Column({ type: 'date' })
|
||||
nextRunDate!: string;
|
||||
|
||||
@Column({ type: 'date', nullable: true })
|
||||
lastRunDate!: string | null;
|
||||
|
||||
@Column({ type: 'date', nullable: true })
|
||||
endDate!: string | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
maxOccurrences!: number | null;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
generatedCount!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'PENDING' })
|
||||
defaultStatus!: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
tags!: string[] | null;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
customFields!: Record<string, string> | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Category, { eager: true, onDelete: 'RESTRICT' })
|
||||
category!: Category;
|
||||
}
|
||||
@@ -36,6 +36,16 @@ export class User {
|
||||
categoryIds?: string[];
|
||||
} | null;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
shoppingListIntegration!: {
|
||||
enabled?: boolean;
|
||||
baseUrl?: string;
|
||||
apiToken?: string;
|
||||
authMode?: 'bearer' | 'x-api-token' | 'both';
|
||||
ownerId?: string | null;
|
||||
defaultListId?: string | null;
|
||||
} | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -3,7 +3,16 @@ import path from 'node:path';
|
||||
import multer from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadDir), filename: (_req, file, cb) => cb(null, `${Date.now()}-${uuidv4()}${path.extname(file.originalname || '')}`) });
|
||||
export const uploadSingleProof = multer({ storage, limits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 } }).single('proofFile');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => cb(null, `${Date.now()}-${uuidv4()}${path.extname(file.originalname || '')}`)
|
||||
});
|
||||
|
||||
export const uploadProofFiles = multer({
|
||||
storage,
|
||||
limits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, files: 8 }
|
||||
}).any();
|
||||
|
||||
10
api/src/routes/budget.routes.ts
Normal file
10
api/src/routes/budget.routes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { createBudget, deleteBudget, listBudgets, updateBudget } from '../controllers/budget.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const budgetRouter = Router();
|
||||
budgetRouter.use(requireAuth);
|
||||
budgetRouter.get('/', listBudgets);
|
||||
budgetRouter.post('/', createBudget);
|
||||
budgetRouter.put('/:id', updateBudget);
|
||||
budgetRouter.delete('/:id', deleteBudget);
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { addProof, createExpense, deleteExpense, listExpenses, updateExpense } from '../controllers/expense.controller.js';
|
||||
import { addProof, createExpense, deleteExpense, listDuplicates, listExpenses, reviewDuplicate, updateExpense } from '../controllers/expense.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { uploadSingleProof } from '../middleware/upload.js';
|
||||
import { uploadProofFiles } from '../middleware/upload.js';
|
||||
|
||||
export const expenseRouter = Router();
|
||||
expenseRouter.use(requireAuth);
|
||||
expenseRouter.get('/', listExpenses);
|
||||
expenseRouter.post('/', uploadSingleProof, createExpense);
|
||||
expenseRouter.get('/duplicates', listDuplicates);
|
||||
expenseRouter.post('/', uploadProofFiles, createExpense);
|
||||
expenseRouter.put('/:id', updateExpense);
|
||||
expenseRouter.post('/:id/duplicate-review', reviewDuplicate);
|
||||
expenseRouter.delete('/:id', deleteExpense);
|
||||
expenseRouter.post('/:id/proofs', uploadSingleProof, addProof);
|
||||
expenseRouter.post('/:id/proofs', uploadProofFiles, addProof);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { adminRouter } from './admin.routes.js';
|
||||
import { authRouter } from './auth.routes.js';
|
||||
import { budgetRouter } from './budget.routes.js';
|
||||
import { categoryRouter } from './category.routes.js';
|
||||
import { expenseRouter } from './expense.routes.js';
|
||||
import { integrationRouter } from './integration.routes.js';
|
||||
import { merchantRouter } from './merchant.routes.js';
|
||||
import { recurringRouter } from './recurring.routes.js';
|
||||
import { reportRouter } from './report.routes.js';
|
||||
import { statisticsRouter } from './statistics.routes.js';
|
||||
|
||||
@@ -16,4 +19,7 @@ apiRouter.use('/expenses', expenseRouter);
|
||||
apiRouter.use('/statistics', statisticsRouter);
|
||||
apiRouter.use('/merchants', merchantRouter);
|
||||
apiRouter.use('/reports', reportRouter);
|
||||
apiRouter.use('/budgets', budgetRouter);
|
||||
apiRouter.use('/recurring-expenses', recurringRouter);
|
||||
apiRouter.use('/integrations', integrationRouter);
|
||||
apiRouter.use('/admin', adminRouter);
|
||||
|
||||
25
api/src/routes/integration.routes.ts
Normal file
25
api/src/routes/integration.routes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getShoppingListExpenses,
|
||||
getShoppingListLatestExpenses,
|
||||
getShoppingListSettings,
|
||||
getShoppingListSummary,
|
||||
getShoppingLists,
|
||||
importShoppingListAsExpense,
|
||||
importShoppingListItemAsExpense,
|
||||
testShoppingListConnection,
|
||||
updateShoppingListSettings
|
||||
} from '../controllers/integration.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const integrationRouter = Router();
|
||||
integrationRouter.use(requireAuth);
|
||||
integrationRouter.get('/shopping-list', getShoppingListSettings);
|
||||
integrationRouter.put('/shopping-list', updateShoppingListSettings);
|
||||
integrationRouter.post('/shopping-list/test', testShoppingListConnection);
|
||||
integrationRouter.get('/shopping-list/summary', getShoppingListSummary);
|
||||
integrationRouter.get('/shopping-list/latest', getShoppingListLatestExpenses);
|
||||
integrationRouter.get('/shopping-list/lists', getShoppingLists);
|
||||
integrationRouter.get('/shopping-list/lists/:id/expenses', getShoppingListExpenses);
|
||||
integrationRouter.post('/shopping-list/import-list', importShoppingListAsExpense);
|
||||
integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense);
|
||||
11
api/src/routes/recurring.routes.ts
Normal file
11
api/src/routes/recurring.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { createRecurringExpense, deleteRecurringExpense, listRecurringExpenses, processRecurringNow, updateRecurringExpense } from '../controllers/recurring.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const recurringRouter = Router();
|
||||
recurringRouter.use(requireAuth);
|
||||
recurringRouter.get('/', listRecurringExpenses);
|
||||
recurringRouter.post('/', createRecurringExpense);
|
||||
recurringRouter.put('/:id', updateRecurringExpense);
|
||||
recurringRouter.delete('/:id', deleteRecurringExpense);
|
||||
recurringRouter.post('/run', processRecurringNow);
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getPreferences,
|
||||
previewReport,
|
||||
sendReport,
|
||||
updatePreferences
|
||||
} from '../controllers/report.controller.js';
|
||||
import { exportReport, getPreferences, previewReport, sendReport, updatePreferences } from '../controllers/report.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const reportRouter = Router();
|
||||
@@ -14,3 +9,4 @@ reportRouter.get('/preferences', getPreferences);
|
||||
reportRouter.put('/preferences', updatePreferences);
|
||||
reportRouter.post('/preview', previewReport);
|
||||
reportRouter.post('/send', sendReport);
|
||||
reportRouter.get('/export', exportReport);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { getOverview } from '../controllers/statistics.controller.js';
|
||||
import { getCashflow, getOverview } from '../controllers/statistics.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const statisticsRouter = Router();
|
||||
statisticsRouter.use(requireAuth);
|
||||
statisticsRouter.get('/overview', getOverview);
|
||||
statisticsRouter.get('/cashflow', getCashflow);
|
||||
|
||||
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