2025-07-09 16:40:27 +02:00
|
|
|
import { requireSalesOrAdmin } from '@/server/utils/auth';
|
|
|
|
|
import { logAuditEvent } from '@/server/utils/audit-logger';
|
2025-07-04 15:27:43 +02:00
|
|
|
import { getExpenseById } from '@/server/utils/nocodb';
|
|
|
|
|
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
|
|
|
|
import { uploadBuffer } from '@/server/utils/minio';
|
|
|
|
|
import type { Expense } from '@/utils/types';
|
|
|
|
|
|
|
|
|
|
export default defineEventHandler(async (event) => {
|
2025-07-09 16:40:27 +02:00
|
|
|
await requireSalesOrAdmin(event);
|
2025-07-04 15:27:43 +02:00
|
|
|
|
|
|
|
|
const body = await readBody(event);
|
|
|
|
|
const { expenseIds } = body;
|
|
|
|
|
|
|
|
|
|
if (!expenseIds || !Array.isArray(expenseIds) || expenseIds.length === 0) {
|
|
|
|
|
throw createError({
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
statusMessage: 'Expense IDs are required'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[expenses/export-csv] Generating CSV for expenses:', expenseIds);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Get user info for file naming
|
|
|
|
|
const userInfo = event.context.user;
|
|
|
|
|
const userName = userInfo?.preferred_username || userInfo?.email || 'user';
|
|
|
|
|
|
|
|
|
|
// Fetch all expenses
|
|
|
|
|
const expenses: Expense[] = [];
|
|
|
|
|
const failedExpenses: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const id of expenseIds) {
|
|
|
|
|
try {
|
|
|
|
|
const expense = await getExpenseById(id.toString());
|
|
|
|
|
const processedExpense = await processExpenseWithCurrency(expense);
|
|
|
|
|
expenses.push(processedExpense);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`[expenses/export-csv] Failed to fetch expense ${id}:`, error);
|
|
|
|
|
failedExpenses.push(id.toString());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (failedExpenses.length > 0) {
|
|
|
|
|
throw createError({
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
statusMessage: `Failed to fetch expenses: ${failedExpenses.join(', ')}`
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (expenses.length === 0) {
|
|
|
|
|
throw createError({
|
|
|
|
|
statusCode: 404,
|
|
|
|
|
statusMessage: 'No expenses found'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort expenses by date
|
|
|
|
|
expenses.sort((a, b) => new Date(a.Time).getTime() - new Date(b.Time).getTime());
|
|
|
|
|
|
|
|
|
|
// Calculate totals
|
|
|
|
|
const subtotalEUR = expenses.reduce((sum, expense) => {
|
|
|
|
|
if (expense.currency === 'EUR') {
|
|
|
|
|
return sum + (expense.PriceNumber || 0);
|
|
|
|
|
} else {
|
|
|
|
|
// Convert to EUR (assuming PriceNumber is in original currency and we have EUR conversion)
|
|
|
|
|
return sum + (expense.PriceNumber || 0) / (expense.ConversionRate || 1);
|
|
|
|
|
}
|
|
|
|
|
}, 0);
|
|
|
|
|
|
|
|
|
|
const processingFee = subtotalEUR * 0.05;
|
|
|
|
|
const totalWithFee = subtotalEUR + processingFee;
|
|
|
|
|
|
|
|
|
|
// Generate CSV content
|
|
|
|
|
const csvHeaders = [
|
|
|
|
|
'Date',
|
|
|
|
|
'Time',
|
|
|
|
|
'Establishment',
|
|
|
|
|
'Category',
|
|
|
|
|
'Payment Method',
|
|
|
|
|
'Payer',
|
|
|
|
|
'Amount (EUR)',
|
|
|
|
|
'Original Amount',
|
|
|
|
|
'Currency',
|
|
|
|
|
'Conversion Rate',
|
|
|
|
|
'Description',
|
|
|
|
|
'Receipt Count',
|
|
|
|
|
'Paid Status'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const csvRows = expenses.map(expense => [
|
|
|
|
|
new Date(expense.Time).toLocaleDateString(),
|
|
|
|
|
new Date(expense.Time).toLocaleTimeString(),
|
|
|
|
|
`"${(expense['Establishment Name'] || '').replace(/"/g, '""')}"`,
|
|
|
|
|
expense.Category || '',
|
|
|
|
|
expense['Payment Method'] || '',
|
|
|
|
|
expense.Payer || '',
|
|
|
|
|
expense.currency === 'EUR' ?
|
|
|
|
|
(expense.PriceNumber || 0).toFixed(2) :
|
|
|
|
|
((expense.PriceNumber || 0) / (expense.ConversionRate || 1)).toFixed(2),
|
|
|
|
|
(expense.PriceNumber || 0).toFixed(2),
|
|
|
|
|
expense.currency || '',
|
|
|
|
|
expense.ConversionRate || 1,
|
|
|
|
|
`"${(expense.Contents || '').replace(/"/g, '""')}"`,
|
|
|
|
|
expense.Receipt?.length || 0,
|
|
|
|
|
expense.Paid ? 'Yes' : 'No'
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Add summary rows
|
|
|
|
|
csvRows.push([]);
|
|
|
|
|
csvRows.push(['SUMMARY']);
|
|
|
|
|
csvRows.push(['Subtotal (EUR)', '', '', '', '', '', subtotalEUR.toFixed(2)]);
|
|
|
|
|
csvRows.push(['Processing Fee (5%)', '', '', '', '', '', processingFee.toFixed(2)]);
|
|
|
|
|
csvRows.push(['Total with Fee (EUR)', '', '', '', '', '', totalWithFee.toFixed(2)]);
|
|
|
|
|
|
|
|
|
|
const csvContent = [
|
|
|
|
|
csvHeaders.join(','),
|
|
|
|
|
...csvRows.map(row => row.join(','))
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
// Generate filename with date range
|
|
|
|
|
const dates = expenses.map(e => new Date(e.Time));
|
|
|
|
|
const startDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
|
|
|
|
const endDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
|
|
|
|
|
|
|
|
|
const formatDate = (date: Date) => date.toISOString().split('T')[0];
|
|
|
|
|
const filename = `expenses_${userName}_${formatDate(startDate)}_to_${formatDate(endDate)}.csv`;
|
|
|
|
|
|
|
|
|
|
// Store CSV in MinIO
|
|
|
|
|
const csvBuffer = Buffer.from(csvContent, 'utf8');
|
|
|
|
|
const year = new Date().getFullYear();
|
|
|
|
|
const month = String(new Date().getMonth() + 1).padStart(2, '0');
|
|
|
|
|
const storagePath = `expense-sheets/${year}/${month}/${filename}`;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await uploadBuffer(csvBuffer, storagePath, 'text/csv');
|
|
|
|
|
console.log(`[expenses/export-csv] CSV stored at: ${storagePath}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[expenses/export-csv] Failed to store CSV in MinIO:', error);
|
|
|
|
|
// Continue with direct download even if storage fails
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return CSV for direct download
|
|
|
|
|
setHeader(event, 'Content-Type', 'text/csv');
|
|
|
|
|
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`);
|
2025-07-09 16:40:27 +02:00
|
|
|
setHeader(event, 'Content-Length', csvBuffer.length);
|
2025-07-04 15:27:43 +02:00
|
|
|
|
|
|
|
|
return csvContent;
|
|
|
|
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error('[expenses/export-csv] Error generating CSV:', error);
|
|
|
|
|
|
|
|
|
|
throw createError({
|
|
|
|
|
statusCode: 500,
|
|
|
|
|
statusMessage: error.message || 'Failed to generate CSV export'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|