changes
This commit is contained in:
39
api/package-lock.json
generated
39
api/package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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) });
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user