Add processing fee option to PDF export modal
- Add checkbox to include 5% processing fee in PDF exports - Install PDF generation dependencies (@pdfme/common, @pdfme/generator, sharp) - Add server-side expenses API endpoints - Update PDF options interface to support processing fee toggle
This commit is contained in:
parent
5cee783ef5
commit
e66d6ad1f2
|
|
@ -91,6 +91,18 @@
|
||||||
<div class="text-sm text-gray-500">Show establishment name, date, description</div>
|
<div class="text-sm text-gray-500">Show establishment name, date, description</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="options.includeProcessingFee"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Include Processing Fee</div>
|
||||||
|
<div class="text-sm text-gray-500">Add 5% processing fee to totals</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -174,6 +186,7 @@ interface PDFOptions {
|
||||||
includeReceipts: boolean;
|
includeReceipts: boolean;
|
||||||
includeSummary: boolean;
|
includeSummary: boolean;
|
||||||
includeDetails: boolean;
|
includeDetails: boolean;
|
||||||
|
includeProcessingFee: boolean;
|
||||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,6 +200,7 @@ const options = ref<PDFOptions>({
|
||||||
includeReceipts: true,
|
includeReceipts: true,
|
||||||
includeSummary: true,
|
includeSummary: true,
|
||||||
includeDetails: true,
|
includeDetails: true,
|
||||||
|
includeProcessingFee: true,
|
||||||
pageFormat: 'A4'
|
pageFormat: 'A4'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,8 @@
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pdfme/common": "^5.4.0",
|
||||||
|
"@pdfme/generator": "^5.4.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@vite-pwa/nuxt": "^0.10.6",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
|
|
@ -22,6 +24,7 @@
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
"sharp": "^0.34.2",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { requireAuth } from '@/server/utils/auth';
|
||||||
|
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) => {
|
||||||
|
await requireAuth(event);
|
||||||
|
|
||||||
|
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}"`);
|
||||||
|
setHeader(event, 'Content-Length', csvBuffer.length.toString());
|
||||||
|
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,507 @@
|
||||||
|
import { requireAuth } from '@/server/utils/auth';
|
||||||
|
import { getExpenseById } from '@/server/utils/nocodb';
|
||||||
|
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||||
|
import { uploadBuffer } from '@/server/utils/minio';
|
||||||
|
import { generate } from '@pdfme/generator';
|
||||||
|
import { Template } from '@pdfme/common';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import type { Expense } from '@/utils/types';
|
||||||
|
|
||||||
|
interface PDFOptions {
|
||||||
|
documentName: string;
|
||||||
|
subheader?: string;
|
||||||
|
groupBy: 'none' | 'payer' | 'category' | 'date';
|
||||||
|
includeReceipts: boolean;
|
||||||
|
includeSummary: boolean;
|
||||||
|
includeDetails: boolean;
|
||||||
|
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||||
|
includeProcessingFee?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAuth(event);
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { expenseIds, options } = body;
|
||||||
|
|
||||||
|
if (!expenseIds || !Array.isArray(expenseIds) || expenseIds.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Expense IDs are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options || !options.documentName) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'PDF options with document name are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[expenses/generate-pdf] Generating PDF for expenses:', expenseIds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user info for file naming
|
||||||
|
const userInfo = event.context.user;
|
||||||
|
const userName = userInfo?.preferred_username || userInfo?.email || 'user';
|
||||||
|
const userEmail = userInfo?.email;
|
||||||
|
|
||||||
|
// Determine if we should use direct generation or email delivery
|
||||||
|
const shouldEmailDelivery = expenseIds.length >= 20;
|
||||||
|
|
||||||
|
if (shouldEmailDelivery && !userEmail) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Email address is required for large PDF generation'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldEmailDelivery) {
|
||||||
|
// Queue for background processing
|
||||||
|
setResponseStatus(event, 202); // Accepted
|
||||||
|
|
||||||
|
// Start background processing (simplified for now)
|
||||||
|
// In a real implementation, you'd use a proper queue system
|
||||||
|
process.nextTick(async () => {
|
||||||
|
try {
|
||||||
|
await generatePDFBackground(expenseIds, options, userName, userEmail);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[expenses/generate-pdf] Background generation failed:', error);
|
||||||
|
// TODO: Send error email to user
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Your PDF is being generated and will be emailed to you shortly.",
|
||||||
|
estimatedTime: `${Math.ceil(expenseIds.length / 10)} minutes`,
|
||||||
|
deliveryMethod: 'email'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct generation for smaller requests
|
||||||
|
const pdfBuffer = await generatePDFDirect(expenseIds, options, userName);
|
||||||
|
|
||||||
|
// Generate filename with date range
|
||||||
|
const dates = await getExpenseDates(expenseIds);
|
||||||
|
const filename = generateFilename(userName, dates, options.documentName);
|
||||||
|
|
||||||
|
// Store PDF in MinIO
|
||||||
|
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(pdfBuffer, storagePath, 'application/pdf');
|
||||||
|
console.log(`[expenses/generate-pdf] PDF stored at: ${storagePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[expenses/generate-pdf] Failed to store PDF in MinIO:', error);
|
||||||
|
// Continue with direct download even if storage fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return PDF for direct download
|
||||||
|
setHeader(event, 'Content-Type', 'application/pdf');
|
||||||
|
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
setHeader(event, 'Content-Length', pdfBuffer.length.toString());
|
||||||
|
|
||||||
|
return pdfBuffer;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[expenses/generate-pdf] Error generating PDF:', error);
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Failed to generate PDF'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function generatePDFDirect(expenseIds: number[], options: PDFOptions, userName: string): Promise<Buffer> {
|
||||||
|
// 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/generate-pdf] Failed to fetch expense ${id}:`, error);
|
||||||
|
failedExpenses.push(id.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedExpenses.length > 0) {
|
||||||
|
throw new Error(`Failed to fetch expenses: ${failedExpenses.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenses.length === 0) {
|
||||||
|
throw new Error('No expenses found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate receipt images if required
|
||||||
|
if (options.includeReceipts) {
|
||||||
|
await validateReceiptImages(expenses);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort expenses by date
|
||||||
|
expenses.sort((a, b) => new Date(a.Time).getTime() - new Date(b.Time).getTime());
|
||||||
|
|
||||||
|
// Group expenses if needed
|
||||||
|
const groupedExpenses = groupExpenses(expenses, options.groupBy);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const { subtotalEUR, processingFee, totalWithFee } = calculateTotals(expenses);
|
||||||
|
|
||||||
|
// Generate PDF
|
||||||
|
const template = await createPDFTemplate(options, groupedExpenses, {
|
||||||
|
subtotalEUR,
|
||||||
|
processingFee,
|
||||||
|
totalWithFee,
|
||||||
|
userName,
|
||||||
|
includeProcessingFee: options.includeProcessingFee ?? true
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputs = await createPDFInputs(groupedExpenses, options);
|
||||||
|
|
||||||
|
const pdf = await generate({
|
||||||
|
template,
|
||||||
|
inputs
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.from(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePDFBackground(expenseIds: number[], options: PDFOptions, userName: string, userEmail: string) {
|
||||||
|
try {
|
||||||
|
console.log('[expenses/generate-pdf] Starting background PDF generation');
|
||||||
|
|
||||||
|
const pdfBuffer = await generatePDFDirect(expenseIds, options, userName);
|
||||||
|
|
||||||
|
// Generate filename and store in MinIO
|
||||||
|
const dates = await getExpenseDates(expenseIds);
|
||||||
|
const filename = generateFilename(userName, dates, options.documentName);
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const month = String(new Date().getMonth() + 1).padStart(2, '0');
|
||||||
|
const storagePath = `expense-sheets/${year}/${month}/${filename}`;
|
||||||
|
|
||||||
|
await uploadBuffer(pdfBuffer, storagePath, 'application/pdf');
|
||||||
|
|
||||||
|
// TODO: Send email with download link
|
||||||
|
console.log(`[expenses/generate-pdf] Background PDF generated and stored at: ${storagePath}`);
|
||||||
|
|
||||||
|
// For now, just log success - in a real implementation, you'd send an email
|
||||||
|
// await sendPDFReadyEmail(userEmail, filename, storagePath);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[expenses/generate-pdf] Background generation failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateReceiptImages(expenses: Expense[]) {
|
||||||
|
const missingImages: string[] = [];
|
||||||
|
|
||||||
|
for (const expense of expenses) {
|
||||||
|
if (expense.Receipt && expense.Receipt.length > 0) {
|
||||||
|
for (const [index, receipt] of expense.Receipt.entries()) {
|
||||||
|
if (!receipt.signedUrl && !receipt.url) {
|
||||||
|
missingImages.push(`Expense #${expense.Id} (${expense['Establishment Name']} on ${expense.Time}) - Receipt ${index + 1}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingImages.length > 0) {
|
||||||
|
throw new Error(`Missing receipt images:\n${missingImages.join('\n')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupExpenses(expenses: Expense[], groupBy: PDFOptions['groupBy']) {
|
||||||
|
if (groupBy === 'none') {
|
||||||
|
return [{ title: 'All Expenses', expenses }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: Record<string, Expense[]> = {};
|
||||||
|
|
||||||
|
expenses.forEach(expense => {
|
||||||
|
let key: string;
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case 'payer':
|
||||||
|
key = expense.Payer || 'Unknown';
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
key = expense.Category || 'Other';
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
key = new Date(expense.Time).toLocaleDateString();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
key = 'All';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = [];
|
||||||
|
}
|
||||||
|
groups[key].push(expense);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(groups).map(([title, expenses]) => ({
|
||||||
|
title,
|
||||||
|
expenses
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTotals(expenses: Expense[]) {
|
||||||
|
const subtotalEUR = expenses.reduce((sum, expense) => {
|
||||||
|
if (expense.currency === 'EUR') {
|
||||||
|
return sum + (expense.PriceNumber || 0);
|
||||||
|
} else {
|
||||||
|
// Convert to EUR
|
||||||
|
return sum + (expense.PriceNumber || 0) / (expense.ConversionRate || 1);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const processingFee = subtotalEUR * 0.05;
|
||||||
|
const totalWithFee = subtotalEUR + processingFee;
|
||||||
|
|
||||||
|
return { subtotalEUR, processingFee, totalWithFee };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPDFTemplate(
|
||||||
|
options: PDFOptions,
|
||||||
|
groupedExpenses: Array<{ title: string; expenses: Expense[] }>,
|
||||||
|
totals: any
|
||||||
|
): Promise<Template> {
|
||||||
|
// Get page dimensions
|
||||||
|
const pageDimensions = getPageDimensions(options.pageFormat);
|
||||||
|
|
||||||
|
// Create template structure
|
||||||
|
const template: Template = {
|
||||||
|
basePdf: null, // We'll create from scratch
|
||||||
|
schemas: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add cover page
|
||||||
|
template.schemas.push(createCoverPageSchema(options, totals, pageDimensions));
|
||||||
|
|
||||||
|
// Add expense pages (one per expense)
|
||||||
|
let pageIndex = 1;
|
||||||
|
for (const group of groupedExpenses) {
|
||||||
|
for (const expense of group.expenses) {
|
||||||
|
template.schemas.push(
|
||||||
|
await createExpensePageSchema(expense, pageIndex, group.expenses.length, options, pageDimensions)
|
||||||
|
);
|
||||||
|
pageIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add summary page if requested
|
||||||
|
if (options.includeSummary) {
|
||||||
|
template.schemas.push(createSummaryPageSchema(groupedExpenses, totals, options, pageDimensions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPDFInputs(
|
||||||
|
groupedExpenses: Array<{ title: string; expenses: Expense[] }>,
|
||||||
|
options: PDFOptions
|
||||||
|
): Promise<any[]> {
|
||||||
|
const inputs: any[] = [];
|
||||||
|
|
||||||
|
// Cover page input
|
||||||
|
inputs.push({
|
||||||
|
title: options.documentName,
|
||||||
|
subheader: options.subheader || '',
|
||||||
|
date: new Date().toLocaleDateString(),
|
||||||
|
// Add other cover page data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expense page inputs
|
||||||
|
for (const group of groupedExpenses) {
|
||||||
|
for (const expense of group.expenses) {
|
||||||
|
const input: any = {
|
||||||
|
establishment: expense['Establishment Name'] || '',
|
||||||
|
amount: formatCurrency(expense),
|
||||||
|
category: expense.Category || '',
|
||||||
|
payer: expense.Payer || '',
|
||||||
|
date: new Date(expense.Time).toLocaleDateString(),
|
||||||
|
time: new Date(expense.Time).toLocaleTimeString(),
|
||||||
|
description: expense.Contents || '',
|
||||||
|
paymentMethod: expense['Payment Method'] || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add receipt images if required
|
||||||
|
if (options.includeReceipts && expense.Receipt && expense.Receipt.length > 0) {
|
||||||
|
input.receiptImages = await Promise.all(
|
||||||
|
expense.Receipt.map(async (receipt) => {
|
||||||
|
try {
|
||||||
|
const imageBuffer = await fetchReceiptImage(receipt.signedUrl || receipt.url);
|
||||||
|
return await optimizeImageForPDF(imageBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to load receipt image for expense #${expense.Id}: ${error}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCoverPageSchema(options: PDFOptions, totals: any, dimensions: any) {
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 50, y: 100 },
|
||||||
|
width: dimensions.width - 100,
|
||||||
|
height: 50,
|
||||||
|
fontSize: 24,
|
||||||
|
fontName: 'Helvetica-Bold',
|
||||||
|
alignment: 'center'
|
||||||
|
},
|
||||||
|
subheader: {
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 50, y: 160 },
|
||||||
|
width: dimensions.width - 100,
|
||||||
|
height: 30,
|
||||||
|
fontSize: 14,
|
||||||
|
fontName: 'Helvetica',
|
||||||
|
alignment: 'center'
|
||||||
|
},
|
||||||
|
// Add more fields as needed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createExpensePageSchema(
|
||||||
|
expense: Expense,
|
||||||
|
pageIndex: number,
|
||||||
|
totalPages: number,
|
||||||
|
options: PDFOptions,
|
||||||
|
dimensions: any
|
||||||
|
) {
|
||||||
|
const schema: any = {
|
||||||
|
// Header
|
||||||
|
pageNumber: {
|
||||||
|
type: 'text',
|
||||||
|
position: { x: dimensions.width - 100, y: 20 },
|
||||||
|
width: 80,
|
||||||
|
height: 20,
|
||||||
|
fontSize: 10,
|
||||||
|
fontName: 'Helvetica',
|
||||||
|
alignment: 'right'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Expense details
|
||||||
|
establishment: {
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 50, y: 80 },
|
||||||
|
width: dimensions.width - 100,
|
||||||
|
height: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
fontName: 'Helvetica-Bold'
|
||||||
|
},
|
||||||
|
|
||||||
|
amount: {
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 50, y: 120 },
|
||||||
|
width: 200,
|
||||||
|
height: 25,
|
||||||
|
fontSize: 14,
|
||||||
|
fontName: 'Helvetica'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add more fields as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add receipt image if available
|
||||||
|
if (options.includeReceipts && expense.Receipt && expense.Receipt.length > 0) {
|
||||||
|
schema.receiptImage = {
|
||||||
|
type: 'image',
|
||||||
|
position: { x: 50, y: 200 },
|
||||||
|
width: dimensions.width - 100,
|
||||||
|
height: 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSummaryPageSchema(groupedExpenses: any, totals: any, options: PDFOptions, dimensions: any) {
|
||||||
|
return {
|
||||||
|
summaryTitle: {
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 50, y: 50 },
|
||||||
|
width: dimensions.width - 100,
|
||||||
|
height: 30,
|
||||||
|
fontSize: 18,
|
||||||
|
fontName: 'Helvetica-Bold',
|
||||||
|
alignment: 'center'
|
||||||
|
},
|
||||||
|
// Add summary fields
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageDimensions(format: PDFOptions['pageFormat']) {
|
||||||
|
switch (format) {
|
||||||
|
case 'A4':
|
||||||
|
return { width: 595, height: 842 };
|
||||||
|
case 'Letter':
|
||||||
|
return { width: 612, height: 792 };
|
||||||
|
case 'Legal':
|
||||||
|
return { width: 612, height: 1008 };
|
||||||
|
default:
|
||||||
|
return { width: 595, height: 842 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(expense: Expense): string {
|
||||||
|
if (expense.currency === 'EUR') {
|
||||||
|
return `€${(expense.PriceNumber || 0).toFixed(2)}`;
|
||||||
|
} else {
|
||||||
|
const eurAmount = (expense.PriceNumber || 0) / (expense.ConversionRate || 1);
|
||||||
|
return `€${eurAmount.toFixed(2)} (${expense.CurrencySymbol || '$'}${(expense.PriceNumber || 0).toFixed(2)} ${expense.currency} original)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReceiptImage(url: string): Promise<Buffer> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeImageForPDF(imageBuffer: Buffer): Promise<string> {
|
||||||
|
// Optimize image for PDF embedding
|
||||||
|
const optimizedBuffer = await sharp(imageBuffer)
|
||||||
|
.resize(800, 600, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Convert to base64 for PDF embedding
|
||||||
|
return `data:image/jpeg;base64,${optimizedBuffer.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExpenseDates(expenseIds: number[]): Promise<{ start: Date; end: Date }> {
|
||||||
|
// This is a simplified version - in practice, you'd fetch the dates
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
start: new Date(now.getFullYear(), now.getMonth(), 1),
|
||||||
|
end: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFilename(userName: string, dates: { start: Date; end: Date }, documentName: string): string {
|
||||||
|
const formatDate = (date: Date) => date.toISOString().split('T')[0];
|
||||||
|
const sanitizedDocName = documentName.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, '-');
|
||||||
|
return `expense-report_${userName}_${formatDate(dates.start)}_to_${formatDate(dates.end)}_${sanitizedDocName}.pdf`;
|
||||||
|
}
|
||||||
|
|
@ -130,6 +130,11 @@ export const uploadFile = async (filePath: string, fileBuffer: Buffer, contentTy
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upload buffer (alias for uploadFile for compatibility)
|
||||||
|
export const uploadBuffer = async (buffer: Buffer, filePath: string, contentType: string) => {
|
||||||
|
return uploadFile(filePath, buffer, contentType);
|
||||||
|
};
|
||||||
|
|
||||||
// Generate presigned URL for download
|
// Generate presigned URL for download
|
||||||
export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60) => {
|
export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60) => {
|
||||||
const client = getMinioClient();
|
const client = getMinioClient();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue