This commit is contained in:
Mateusz Gruszczyński
2026-04-07 10:06:48 +02:00
parent deaa6dfe43
commit ca9c78d88d
36 changed files with 1801 additions and 503 deletions

39
api/package-lock.json generated
View File

@@ -604,9 +604,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -621,9 +618,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -638,9 +632,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -655,9 +646,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -672,9 +660,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -689,9 +674,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -706,9 +688,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -723,9 +702,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -740,9 +716,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -757,9 +730,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -774,9 +744,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -791,9 +758,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -808,9 +772,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View File

@@ -36,7 +36,8 @@ const settingsSchema = z.object({
const userUpdateSchema = z.object({
role: z.enum(['ADMIN', 'USER']).optional(),
isActive: z.boolean().optional(),
defaultCurrency: z.string().min(3).max(8).optional()
defaultCurrency: z.string().min(3).max(8).optional(),
integrationsEnabled: z.boolean().optional()
});
const require = createRequire(import.meta.url);
@@ -172,6 +173,7 @@ export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
if (parsed.data.role) item.role = parsed.data.role;
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
if (typeof parsed.data.integrationsEnabled === 'boolean') item.integrationsEnabled = parsed.data.integrationsEnabled;
await userRepo().save(item);
return res.json({ item: sanitizeUser(item) });

View File

@@ -49,6 +49,11 @@ const serializeBudget = (item: Budget, spent: number) => {
};
const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` });
const normalizeNullableText = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : null;
};
export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id);
@@ -96,7 +101,7 @@ export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
};
export const createBudget = async (req: AuthenticatedRequest, res: Response) => {
const parsed = budgetSchema.safeParse(req.body);
const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
const category = parsed.data.categoryId
@@ -125,7 +130,7 @@ export const createBudget = async (req: AuthenticatedRequest, res: Response) =>
};
export const updateBudget = async (req: AuthenticatedRequest, res: Response) => {
const parsed = budgetSchema.safeParse(req.body);
const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
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 } });

View File

@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { Request, Response } from 'express';
import { In } from 'typeorm';
import { z } from 'zod';
import { AppDataSource } from '../config/data-source.js';
import { env } from '../config/env.js';
@@ -16,6 +17,10 @@ const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']
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 expenseStatusUpdateSchema = z.object({ status: statusSchema });
const bulkIdsSchema = z.array(z.string().uuid()).min(1).max(200);
const bulkExpenseStatusUpdateSchema = z.object({ ids: bulkIdsSchema, status: statusSchema });
const bulkExpenseDeleteSchema = z.object({ ids: bulkIdsSchema });
const createExpenseSchema = z.object({
title: z.string().min(2).max(140),
@@ -45,7 +50,11 @@ const updateExpenseSchema = z.object({
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({})
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(),
removeProofIds: z.array(z.string().uuid()).default([])
});
const addProofSchema = z.object({
@@ -75,6 +84,53 @@ const removeUploadedFiles = (files: Express.Multer.File[]) => {
files.forEach((file) => removeUploadedFile(file.filename));
};
const slugifyForFilename = (value: string) =>
value
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'expense';
const deriveFileExtension = (file: Express.Multer.File) => {
const fromOriginal = path.extname(file.originalname || '').trim();
if (fromOriginal) return fromOriginal.toLowerCase();
const fromStored = path.extname(file.filename || '').trim();
if (fromStored) return fromStored.toLowerCase();
if ((file.mimetype || '').toLowerCase().includes('pdf')) return '.pdf';
if ((file.mimetype || '').toLowerCase().includes('png')) return '.png';
if ((file.mimetype || '').toLowerCase().includes('jpeg') || (file.mimetype || '').toLowerCase().includes('jpg')) return '.jpg';
if ((file.mimetype || '').toLowerCase().includes('webp')) return '.webp';
return '';
};
const renameUploadedFilesForExpense = (files: Express.Multer.File[], expenseTitle: string, proofType?: string | null) => {
const base = [slugifyForFilename(expenseTitle), proofType?.toLowerCase() || null].filter(Boolean).join('-');
files.forEach((file, index) => {
const extension = deriveFileExtension(file);
const nextFilename = `${base || 'expense'}-${Date.now()}-${index + 1}${extension}`.slice(0, 220);
const fromPath = path.resolve(env.UPLOAD_DIR, file.filename);
const toPath = path.resolve(env.UPLOAD_DIR, nextFilename);
if (fromPath !== toPath && fs.existsSync(fromPath)) fs.renameSync(fromPath, toPath);
file.filename = nextFilename;
file.originalname = nextFilename;
});
};
const normalizeIdList = (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 normalizeTagList = (value: unknown) => {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
if (typeof value === 'string') {
@@ -116,7 +172,8 @@ const normalizeCustomFields = (value: unknown) => {
const enrichPayload = (body: Record<string, unknown>) => ({
...body,
tags: normalizeTagList(body.tags),
customFields: normalizeCustomFields(body.customFields)
customFields: normalizeCustomFields(body.customFields),
removeProofIds: normalizeIdList(body.removeProofIds)
});
const serializeExpense = (expense: Expense) => ({
@@ -142,7 +199,7 @@ const serializeExpense = (expense: Expense) => ({
isSystem: expense.category.isSystem,
ownerId: expense.category.user?.id ?? null
},
proofs: expense.proofs?.map(serializeProof) ?? [],
proofs: expense.proofs?.map((proof) => serializeProof(proof, expense.id)) ?? [],
createdAt: expense.createdAt,
updatedAt: expense.updatedAt
});
@@ -191,6 +248,9 @@ const hydrateExpense = (id: string) =>
const parseFilterArray = (value: string | undefined) => value?.split(',').map((item) => item.trim()).filter(Boolean) ?? [];
const sortBySchema = z.enum(['expenseDate', 'title', 'amount', 'status', 'category', 'merchant', 'createdAt', 'updatedAt']).catch('expenseDate');
const sortDirSchema = z.enum(['asc', 'desc']).catch('desc');
const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED'];
const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
DRAFT: ['DRAFT', 'PENDING', 'REJECTED'],
@@ -200,9 +260,25 @@ const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
};
const validateInitialStatus = (nextStatus: ExpenseStatus) => initialStatuses.includes(nextStatus);
const parsePositiveInt = (value: unknown, fallback: number, max = 100) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), 1), max);
};
const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus);
const approvalNeedsProof = (nextStatus: ExpenseStatus) => nextStatus === 'APPROVED';
const loadOwnedExpenses = async (ids: string[], user: AuthenticatedRequest['user']) => {
const where = user?.role === 'ADMIN'
? { id: In(ids) }
: { id: In(ids), user: { id: user!.id } };
return expenseRepo().find({
where,
relations: { user: true, category: { user: true }, proofs: true }
});
};
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
if (!duplicates.length) {
expense.possibleDuplicate = false;
@@ -225,9 +301,13 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
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().trim() : undefined;
const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase() : undefined;
const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase().trim() : 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 pageSize = parsePositiveInt(req.query.pageSize, 20, 100);
const requestedPage = parsePositiveInt(req.query.page, 1, 100000);
const sortBy = sortBySchema.parse(req.query.sortBy);
const sortDir = sortDirSchema.parse(req.query.sortDir);
const items = await expenseRepo().find({
where: { user: { id: req.user!.id } },
@@ -255,7 +335,71 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
return true;
});
return res.json({ items: filtered.map(serializeExpense) });
const sorted = [...filtered].sort((left, right) => {
const direction = sortDir === 'asc' ? 1 : -1;
const leftValue = (() => {
switch (sortBy) {
case 'title': return left.title ?? '';
case 'amount': return left.amount ?? 0;
case 'status': return left.status ?? '';
case 'category': return left.category?.name ?? '';
case 'merchant': return left.merchant ?? '';
case 'createdAt': return left.createdAt?.toISOString?.() ?? String(left.createdAt ?? '');
case 'updatedAt': return left.updatedAt?.toISOString?.() ?? String(left.updatedAt ?? '');
case 'expenseDate':
default: return left.expenseDate ?? '';
}
})();
const rightValue = (() => {
switch (sortBy) {
case 'title': return right.title ?? '';
case 'amount': return right.amount ?? 0;
case 'status': return right.status ?? '';
case 'category': return right.category?.name ?? '';
case 'merchant': return right.merchant ?? '';
case 'createdAt': return right.createdAt?.toISOString?.() ?? String(right.createdAt ?? '');
case 'updatedAt': return right.updatedAt?.toISOString?.() ?? String(right.updatedAt ?? '');
case 'expenseDate':
default: return right.expenseDate ?? '';
}
})();
if (typeof leftValue === 'number' && typeof rightValue === 'number') return (leftValue - rightValue) * direction;
return String(leftValue).localeCompare(String(rightValue), 'pl', { sensitivity: 'base' }) * direction;
});
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(requestedPage, totalPages);
const startIndex = (page - 1) * pageSize;
const paged = sorted.slice(startIndex, startIndex + pageSize);
return res.json({
items: paged.map(serializeExpense),
pagination: {
page,
pageSize,
total,
totalPages,
hasPrev: page > 1,
hasNext: page < totalPages
}
});
};
export const getExpense = async (req: AuthenticatedRequest, res: Response) => {
const id = String(req.params.id || '').trim();
const where = req.user?.role === 'ADMIN'
? { id }
: { id, user: { id: req.user!.id } };
const item = await expenseRepo().findOne({
where,
relations: { category: { user: true }, proofs: true, user: true }
});
if (!item) return res.status(404).json({ message: 'Expense not found' });
return res.json({ item: serializeExpense(item) });
};
export const listDuplicates = async (req: AuthenticatedRequest, res: Response) => {
@@ -323,6 +467,8 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
customFields: parsed.data.customFields
});
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
const proofs: Proof[] = uploadedFiles.map((file, index) =>
proofRepo().create({
type: parsed.data.proofType ?? 'OTHER',
@@ -377,8 +523,10 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
};
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
const uploadedFiles = getUploadedFiles(req);
const parsed = updateExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
if (!parsed.success) {
removeUploadedFiles(uploadedFiles);
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
}
@@ -386,24 +534,37 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
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 (!item) {
removeUploadedFiles(uploadedFiles);
return res.status(404).json({ message: 'Expense not found' });
}
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
removeUploadedFiles(uploadedFiles);
return res.status(403).json({ message: 'You cannot edit this expense' });
}
if (!validateStatusTransition(item.status, parsed.data.status)) {
removeUploadedFiles(uploadedFiles);
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' });
if (!category) {
removeUploadedFiles(uploadedFiles);
return res.status(404).json({ message: 'Category not found' });
}
const proofIdsToRemove = new Set(parsed.data.removeProofIds);
const remainingProofs = item.proofs.filter((proof) => !proofIdsToRemove.has(proof.id));
if (approvalNeedsProof(parsed.data.status) && remainingProofs.length + uploadedFiles.length === 0) {
removeUploadedFiles(uploadedFiles);
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
}
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
const duplicates = await findDuplicateMatches({
userId: req.user!.id,
@@ -428,11 +589,122 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
item.category = category;
applyDuplicateState(item, duplicates);
if (proofIdsToRemove.size) {
const proofsToDelete = item.proofs.filter((proof) => proofIdsToRemove.has(proof.id));
proofsToDelete.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined));
if (proofsToDelete.length) await proofRepo().remove(proofsToDelete);
item.proofs = remainingProofs;
}
const createdProofs = 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,
expense: item
})
);
if (!createdProofs.length && !item.proofs.length && (parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType)) {
createdProofs.push(
proofRepo().create({
type: parsed.data.proofType ?? 'OTHER',
label: parsed.data.proofLabel ?? 'Attachment',
note: parsed.data.proofNote ?? null,
originalName: null,
storedName: null,
mimeType: null,
fileSize: null,
expense: item
})
);
}
await expenseRepo().save(item);
if (createdProofs.length) await proofRepo().save(createdProofs);
const refreshed = await hydrateExpense(item.id);
return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
};
export const bulkUpdateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
const parsed = bulkExpenseStatusUpdateSchema.safeParse({
ids: Array.isArray(req.body?.ids) ? req.body.ids : [],
status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status
});
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense status payload', issues: parsed.error.issues });
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
const invalidTransition = items.find((item) => !validateStatusTransition(item.status, parsed.data.status));
if (invalidTransition) {
return res.status(400).json({ message: `Status transition from ${invalidTransition.status} to ${parsed.data.status} is not allowed for ${invalidTransition.title}.` });
}
const missingProof = items.find((item) => approvalNeedsProof(parsed.data.status) && !item.proofs.length);
if (missingProof) {
return res.status(400).json({ message: `Add at least one attachment before approving ${missingProof.title}.` });
}
items.forEach((item) => {
item.status = parsed.data.status;
});
await expenseRepo().save(items);
const refreshed = await expenseRepo().find({
where: { id: In(items.map((item) => item.id)) },
relations: { category: { user: true }, proofs: true, user: true }
});
return res.json({ items: refreshed.map(serializeExpense), updated: refreshed.length });
};
export const bulkDeleteExpenses = async (req: AuthenticatedRequest, res: Response) => {
const parsed = bulkExpenseDeleteSchema.safeParse({ ids: Array.isArray(req.body?.ids) ? req.body.ids : [] });
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense delete payload', issues: parsed.error.issues });
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
items.forEach((item) => item.proofs.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined)));
await expenseRepo().remove(items);
return res.json({ deleted: items.length });
};
export const updateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
const parsed = expenseStatusUpdateSchema.safeParse({ status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status });
if (!parsed.success) return res.status(400).json({ message: 'Invalid expense status 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 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) {
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
}
item.status = parsed.data.status;
await expenseRepo().save(item);
const refreshed = await hydrateExpense(item.id);
return res.json({ item: serializeExpense(refreshed) });
};
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 });
@@ -494,6 +766,47 @@ export const deleteExpense = async (req: AuthenticatedRequest, res: Response) =>
return res.status(204).send();
};
export const deleteProof = async (req: AuthenticatedRequest, res: Response) => {
const expense = await expenseRepo().findOne({
where: { id: String(req.params.id) },
relations: { user: true, proofs: true, category: { user: true } }
});
if (!expense) return res.status(404).json({ message: 'Expense not found' });
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
return res.status(403).json({ message: 'You cannot delete proof from this expense' });
}
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
if (!proof) return res.status(404).json({ message: 'Proof not found' });
removeUploadedFile(proof.storedName ?? undefined);
await proofRepo().remove(proof);
const refreshed = await hydrateExpense(expense.id);
return res.json({ item: serializeExpense(refreshed) });
};
export const getProofFile = async (req: AuthenticatedRequest, res: Response) => {
const expense = await expenseRepo().findOne({
where: { id: String(req.params.id) },
relations: { user: true, proofs: true }
});
if (!expense) return res.status(404).json({ message: 'Expense not found' });
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
return res.status(403).json({ message: 'You cannot access files for this expense' });
}
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
if (!proof || !proof.storedName) return res.status(404).json({ message: 'Proof file not found' });
const filePath = path.resolve(env.UPLOAD_DIR, proof.storedName);
if (!fs.existsSync(filePath)) return res.status(404).json({ message: 'Proof file not found' });
res.setHeader('Content-Disposition', `inline; filename="${proof.originalName || proof.storedName}"`);
if (proof.mimeType) res.type(proof.mimeType);
return res.sendFile(filePath);
};
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
const uploadedFiles = getUploadedFiles(req);
const parsed = addProofSchema.safeParse(req.body ?? {});
@@ -515,6 +828,8 @@ export const addProof = async (req: AuthenticatedRequest, res: Response) => {
return res.status(403).json({ message: 'You cannot add proof to this expense' });
}
renameUploadedFilesForExpense(uploadedFiles, expense.title, parsed.data.type);
const createdProofs = uploadedFiles.length
? uploadedFiles.map((file) =>
proofRepo().create({
@@ -543,5 +858,5 @@ export const addProof = async (req: AuthenticatedRequest, res: Response) => {
await proofRepo().save(createdProofs);
const refreshed = await hydrateExpense(expense.id);
return res.status(201).json({ proofs: createdProofs.map(serializeProof), expense: serializeExpense(refreshed) });
};
return res.status(201).json({ proofs: createdProofs.map((proof) => serializeProof(proof, expense.id)), expense: serializeExpense(refreshed) });
};

View File

@@ -54,6 +54,13 @@ const importItemSchema = z.object({
tags: z.array(z.string().min(1).max(40)).default([])
});
const importPeriodSchema = z.object({
period: z.string().regex(/^\d{4}-\d{2}$/),
categoryId: z.string().uuid(),
status: importStatusSchema,
merchant: z.string().max(120).nullable().optional()
});
const userRepo = () => AppDataSource.getRepository(User);
const expenseRepo = () => AppDataSource.getRepository(Expense);
const categoryRepo = () => AppDataSource.getRepository(Category);
@@ -72,6 +79,13 @@ const getSettings = async (userId: string) => {
return user ?? null;
};
const ensureIntegrationsAllowed = (user: User) => {
if (user.role === 'ADMIN' || user.integrationsEnabled) return;
const error = new Error('Integrations are disabled for this user.') as Error & { status?: number };
error.status = 403;
throw error;
};
const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({
enabled: Boolean(value?.enabled),
baseUrl: value?.baseUrl ?? '',
@@ -93,6 +107,7 @@ const buildHeaders = (config: ShoppingListConfig) => {
const requireConfig = async (userId: string) => {
const user = await getSettings(userId);
if (!user) throw new Error('User not found');
ensureIntegrationsAllowed(user);
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 };
@@ -179,6 +194,16 @@ const deriveListDate = (items: Record<string, unknown>[], listCreatedAt?: string
return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10);
};
const monthRange = (period: string) => {
const [yearText, monthText] = period.split('-');
const year = Number(yearText);
const month = Number(monthText);
const start = `${period}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${period}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
};
const resolveCategory = async (userId: string, categoryId: string) =>
categoryRepo().findOne({
where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }],
@@ -312,6 +337,7 @@ const createImportedExpense = async (input: {
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' });
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
};
@@ -321,6 +347,7 @@ export const updateShoppingListSettings = async (req: AuthenticatedRequest, res:
const user = await getSettings(req.user!.id);
if (!user) return res.status(404).json({ message: 'User not found' });
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
const current = user.shoppingListIntegration ?? {};
user.shoppingListIntegration = {
@@ -433,7 +460,7 @@ export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res
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 tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
const result = await createImportedExpense({
userId: user.id,
@@ -461,6 +488,64 @@ export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res
}
};
export const importShoppingPeriodAsExpense = async (req: AuthenticatedRequest, res: Response) => {
const parsed = importPeriodSchema.safeParse(req.body ?? {});
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping period import payload', issues: parsed.error.issues });
try {
const { user, config } = await requireConfig(req.user!.id);
const range = monthRange(parsed.data.period);
const existing = await getExistingExpenses(user.id);
if (hasExternalImport(existing, 'externalShoppingListPeriod', parsed.data.period)) {
return res.status(409).json({ message: 'This shopping month has already been imported as a local expense.' });
}
const payload = await proxyRequest(config, '/api/expenses/summary', {
start_date: range.start,
end_date: range.end,
owner_id: config.ownerId ?? undefined,
list_id: config.defaultListId ?? undefined
});
const totalAmount = readNumber((payload as { total?: unknown }).total, (payload as { amount?: unknown }).amount, (payload as { meta?: { total_amount?: unknown } }).meta?.total_amount);
const totalCount = readNumber((payload as { count?: unknown }).count, (payload as { records?: unknown }).records, (payload as { meta?: { total_count?: unknown } }).meta?.total_count);
const listGroups = [
(payload as { lists?: unknown }).lists,
(payload as { totals?: unknown }).totals,
(payload as { aggregates?: unknown }).aggregates
].find((value) => Array.isArray(value));
const listCount = Array.isArray(listGroups) ? listGroups.length : 0;
if (totalAmount <= 0) {
return res.status(400).json({ message: 'The selected shopping month does not contain any importable expenses.' });
}
const result = await createImportedExpense({
userId: user.id,
categoryId: parsed.data.categoryId,
title: `Zakupy ${parsed.data.period}`,
description: `Zbiorczy import zakupów za okres ${parsed.data.period}.`,
amount: Number(totalAmount.toFixed(2)),
expenseDate: range.end,
merchant: trimToNull(parsed.data.merchant) ?? 'Zakupy',
status: parsed.data.status,
tags: ['shopping-list'],
customFields: {
externalSource: 'shopping-list-api',
externalShoppingListImportType: 'PERIOD',
externalShoppingListPeriod: parsed.data.period,
externalShoppingListListCount: String(listCount),
externalShoppingListItemCount: String(totalCount)
}
});
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 });
@@ -475,7 +560,7 @@ export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest,
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 tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
const description = trimToNull(parsed.data.description) ?? `Imported from shopping list API${parsed.data.listTitle ? ` (${parsed.data.listTitle})` : ''}.`;
const result = await createImportedExpense({

View File

@@ -32,6 +32,18 @@ const require = createRequire(import.meta.url);
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
const expenseRepo = () => AppDataSource.getRepository(Expense);
const normalizeOptionalString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : undefined;
};
const normalizeNullableString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : null;
};
const defaultPrefs = (email: string) => ({
enabled: false,
frequency: 'monthly' as const,
@@ -196,7 +208,7 @@ export const getPreferences = async (req: AuthenticatedRequest, res: Response) =
};
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
const parsed = preferencesSchema.safeParse(req.body);
const parsed = preferencesSchema.safeParse({ ...req.body, sendToEmail: normalizeNullableString(req.body?.sendToEmail) });
if (!parsed.success) {
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
}
@@ -282,7 +294,7 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id);
const parsed = exportQuerySchema.safeParse(req.query);
const parsed = exportQuerySchema.safeParse({ ...req.query, format: normalizeOptionalString(req.query.format), startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), status: normalizeOptionalString(req.query.status), tag: normalizeOptionalString(req.query.tag) });
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) ?? [];

View File

@@ -4,6 +4,12 @@ import { getCashflowSummary, getStatistics } from '../services/statistics.servic
import { processDueRecurringExpenses } from '../services/recurring.service.js';
import type { AuthenticatedRequest } from '../types/express.js';
const normalizeOptionalString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : undefined;
};
const querySchema = z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
@@ -15,11 +21,15 @@ const querySchema = z.object({
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id);
const parsed = querySchema.safeParse(req.query);
const parsed = querySchema.safeParse({ ...req.query, startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), bucket: normalizeOptionalString(req.query.bucket), tag: normalizeOptionalString(req.query.tag), status: normalizeOptionalString(req.query.status) });
if (!parsed.success) {
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
return res.json(
await getStatistics(
@@ -38,5 +48,8 @@ export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
export const getCashflow = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
return res.json(await getCashflowSummary(req.user!.id));
};

View File

@@ -29,6 +29,9 @@ export class User {
@Column({ type: 'varchar', length: 8, default: 'PLN' })
defaultCurrency!: string;
@Column({ type: 'boolean', default: false })
integrationsEnabled!: boolean;
@Column({ type: 'simple-json', nullable: true })
reportPreferences!: {
enabled?: boolean;

View File

@@ -1,5 +1,5 @@
import { Router } from 'express';
import { addProof, createExpense, deleteExpense, listDuplicates, listExpenses, reviewDuplicate, updateExpense } from '../controllers/expense.controller.js';
import { addProof, bulkDeleteExpenses, bulkUpdateExpenseStatus, createExpense, deleteExpense, deleteProof, getExpense, getProofFile, listDuplicates, listExpenses, reviewDuplicate, updateExpense, updateExpenseStatus } from '../controllers/expense.controller.js';
import { requireAuth } from '../middleware/auth.js';
import { uploadProofFiles } from '../middleware/upload.js';
@@ -7,8 +7,15 @@ export const expenseRouter = Router();
expenseRouter.use(requireAuth);
expenseRouter.get('/', listExpenses);
expenseRouter.get('/duplicates', listDuplicates);
expenseRouter.patch('/bulk/status', bulkUpdateExpenseStatus);
expenseRouter.post('/bulk/delete', bulkDeleteExpenses);
expenseRouter.get('/item/:id', getExpense);
expenseRouter.get('/:id', getExpense);
expenseRouter.post('/', uploadProofFiles, createExpense);
expenseRouter.put('/:id', updateExpense);
expenseRouter.put('/:id', uploadProofFiles, updateExpense);
expenseRouter.patch('/:id/status', updateExpenseStatus);
expenseRouter.post('/:id/duplicate-review', reviewDuplicate);
expenseRouter.delete('/:id', deleteExpense);
expenseRouter.get('/:id/proofs/:proofId/file', getProofFile);
expenseRouter.post('/:id/proofs', uploadProofFiles, addProof);
expenseRouter.delete('/:id/proofs/:proofId', deleteProof);

View File

@@ -7,6 +7,7 @@ import {
getShoppingLists,
importShoppingListAsExpense,
importShoppingListItemAsExpense,
importShoppingPeriodAsExpense,
testShoppingListConnection,
updateShoppingListSettings
} from '../controllers/integration.controller.js';
@@ -22,4 +23,5 @@ 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-period', importShoppingPeriodAsExpense);
integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense);

View File

@@ -13,6 +13,7 @@ export const sanitizeUser = (user: User) => ({
role: user.role,
isActive: user.isActive,
defaultCurrency: user.defaultCurrency,
integrationsEnabled: Boolean(user.integrationsEnabled),
reportPreferences: user.reportPreferences ?? {
enabled: false,
frequency: 'monthly',
@@ -47,6 +48,7 @@ export const createUser = async (input: {
passwordHash: await hashPassword(input.password),
role: input.role ?? 'USER',
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
integrationsEnabled: false,
reportPreferences: {
enabled: false,
frequency: 'monthly',

View File

@@ -113,7 +113,13 @@ const currentMonthKey = () => {
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
};
const monthRange = (monthKey: string) => ({ startDate: `${monthKey}-01`, endDate: `${monthKey}-31` });
const monthRange = (monthKey: string) => {
const [yearText, monthText] = monthKey.split('-');
const year = Number(yearText);
const month = Number(monthText);
const lastDay = new Date(year, month, 0).getDate();
return { startDate: `${monthKey}-01`, endDate: `${monthKey}-${String(lastDay).padStart(2, '0')}` };
};
export const getCashflowSummary = async (userId: string) => {
const expenseRepo = AppDataSource.getRepository(Expense);

View File

@@ -1,6 +1,6 @@
import type { Proof } from '../entities/Proof.js';
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
export const serializeProof = (proof: Proof) => ({
export const serializeProof = (proof: Proof, _expenseId?: string) => ({
id: proof.id,
type: proof.type,
label: proof.label,
@@ -9,5 +9,6 @@ export const serializeProof = (proof: Proof) => ({
mimeType: proof.mimeType,
fileSize: proof.fileSize,
fileUrl: buildProofUrl(proof.storedName),
previewUrl: buildProofUrl(proof.storedName),
createdAt: proof.createdAt
});