invoice-system/server.js

1419 lines
44 KiB
JavaScript

const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const puppeteer = require('puppeteer');
const fs = require('fs').promises;
const multer = require('multer');
const app = express();
const PORT = process.env.PORT || 3000;
// Database connection
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'quotes_db',
password: process.env.DB_PASSWORD || 'postgres',
port: process.env.DB_PORT || 5432,
});
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Configure multer for logo upload
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, 'public', 'uploads');
try {
await fs.mkdir(uploadDir, { recursive: true });
} catch (err) {
console.error('Error creating upload directory:', err);
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
cb(null, 'company-logo.png');
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'));
}
}
});
// Helper functions
function formatDate(date) {
const d = new Date(date);
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear();
return `${month}/${day}/${year}`;
}
async function getNextQuoteNumber() {
const year = new Date().getFullYear();
const result = await pool.query(
'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1',
[`${year}-%`]
);
if (result.rows.length === 0) {
return `${year}-001`;
}
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]);
const nextNumber = String(lastNumber + 1).padStart(3, '0');
return `${year}-${nextNumber}`;
}
async function getNextInvoiceNumber() {
const year = new Date().getFullYear();
const result = await pool.query(
'SELECT invoice_number FROM invoices WHERE invoice_number LIKE $1 ORDER BY invoice_number DESC LIMIT 1',
[`${year}-%`]
);
if (result.rows.length === 0) {
return `${year}-001`;
}
const lastNumber = parseInt(result.rows[0].invoice_number.split('-')[1]);
const nextNumber = String(lastNumber + 1).padStart(3, '0');
return `${year}-${nextNumber}`;
}
// Logo endpoints
app.get('/api/logo-info', async (req, res) => {
try {
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
try {
await fs.access(logoPath);
res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' });
} catch {
res.json({ hasLogo: false });
}
} catch (error) {
console.error('Error checking logo:', error);
res.status(500).json({ error: 'Error checking logo' });
}
});
app.post('/api/upload-logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
res.json({
message: 'Logo uploaded successfully',
path: '/uploads/company-logo.png'
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Error uploading logo' });
}
});
// Customer endpoints
app.get('/api/customers', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM customers ORDER BY name');
res.json(result.rows);
} catch (error) {
console.error('Error fetching customers:', error);
res.status(500).json({ error: 'Error fetching customers' });
}
});
app.post('/api/customers', async (req, res) => {
const { name, street, city, state, zip_code, account_number } = req.body;
try {
const result = await pool.query(
'INSERT INTO customers (name, street, city, state, zip_code, account_number) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
[name, street, city, state, zip_code, account_number]
);
res.json(result.rows[0]);
} catch (error) {
console.error('Error creating customer:', error);
res.status(500).json({ error: 'Error creating customer' });
}
});
app.put('/api/customers/:id', async (req, res) => {
const { id } = req.params;
const { name, street, city, state, zip_code, account_number } = req.body;
try {
const result = await pool.query(
'UPDATE customers SET name = $1, street = $2, city = $3, state = $4, zip_code = $5, account_number = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $7 RETURNING *',
[name, street, city, state, zip_code, account_number, id]
);
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating customer:', error);
res.status(500).json({ error: 'Error updating customer' });
}
});
app.delete('/api/customers/:id', async (req, res) => {
const { id } = req.params;
try {
await pool.query('DELETE FROM customers WHERE id = $1', [id]);
res.json({ success: true });
} catch (error) {
console.error('Error deleting customer:', error);
res.status(500).json({ error: 'Error deleting customer' });
}
});
// Quote endpoints
app.get('/api/quotes', async (req, res) => {
try {
const result = await pool.query(`
SELECT q.*, c.name as customer_name
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
ORDER BY q.created_at DESC
`);
res.json(result.rows);
} catch (error) {
console.error('Error fetching quotes:', error);
res.status(500).json({ error: 'Error fetching quotes' });
}
});
app.get('/api/quotes/:id', async (req, res) => {
const { id } = req.params;
try {
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1
`, [id]);
if (quoteResult.rows.length === 0) {
return res.status(404).json({ error: 'Quote not found' });
}
const itemsResult = await pool.query(
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
[id]
);
res.json({
quote: quoteResult.rows[0],
items: itemsResult.rows
});
} catch (error) {
console.error('Error fetching quote:', error);
res.status(500).json({ error: 'Error fetching quote' });
}
});
app.post('/api/quotes', async (req, res) => {
const { customer_id, quote_date, tax_exempt, items } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
const quote_number = await getNextQuoteNumber();
// Calculate totals
let subtotal = 0;
let has_tbd = false;
for (const item of items) {
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
has_tbd = true;
} else {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
subtotal += amount;
}
}
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
// Insert quote
const quoteResult = await client.query(
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd]
);
const quoteId = quoteResult.rows[0].id;
// Insert items
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
);
}
await client.query('COMMIT');
res.json(quoteResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
console.error('Error creating quote:', error);
res.status(500).json({ error: 'Error creating quote' });
} finally {
client.release();
}
});
app.put('/api/quotes/:id', async (req, res) => {
const { id } = req.params;
const { customer_id, quote_date, tax_exempt, items } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Calculate totals
let subtotal = 0;
let has_tbd = false;
for (const item of items) {
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
has_tbd = true;
} else {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
subtotal += amount;
}
}
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
// Update quote
await client.query(
`UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4,
subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP
WHERE id = $9`,
[customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id]
);
// Delete old items and insert new ones
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
);
}
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error updating quote:', error);
res.status(500).json({ error: 'Error updating quote' });
} finally {
client.release();
}
});
app.delete('/api/quotes/:id', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
await client.query('DELETE FROM quotes WHERE id = $1', [id]);
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error deleting quote:', error);
res.status(500).json({ error: 'Error deleting quote' });
} finally {
client.release();
}
});
// Invoice endpoints
app.get('/api/invoices', async (req, res) => {
try {
const result = await pool.query(`
SELECT i.*, c.name as customer_name
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
ORDER BY i.created_at DESC
`);
res.json(result.rows);
} catch (error) {
console.error('Error fetching invoices:', error);
res.status(500).json({ error: 'Error fetching invoices' });
}
});
app.get('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
try {
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [id]);
if (invoiceResult.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
const itemsResult = await pool.query(
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
[id]
);
res.json({
invoice: invoiceResult.rows[0],
items: itemsResult.rows
});
} catch (error) {
console.error('Error fetching invoice:', error);
res.status(500).json({ error: 'Error fetching invoice' });
}
});
app.post('/api/invoices', async (req, res) => {
const { customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
const invoice_number = await getNextInvoiceNumber();
// Calculate totals - invoices should NOT have TBD items
let subtotal = 0;
for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
subtotal += amount;
}
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
// Insert invoice
const invoiceResult = await client.query(
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id]
);
const invoiceId = invoiceResult.rows[0].id;
// Insert items
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
);
}
await client.query('COMMIT');
res.json(invoiceResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
console.error('Error creating invoice:', error);
res.status(500).json({ error: 'Error creating invoice' });
} finally {
client.release();
}
});
app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Get quote details
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1
`, [id]);
if (quoteResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Quote not found' });
}
const quote = quoteResult.rows[0];
// Get quote items
const itemsResult = await pool.query(
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
[id]
);
// Check for TBD items
const hasTBD = itemsResult.rows.some(item =>
item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD'
);
if (hasTBD) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
}
// Create invoice
const invoice_number = await getNextInvoiceNumber();
const invoiceDate = new Date().toISOString().split('T')[0];
const invoiceResult = await client.query(
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[invoice_number, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id]
);
const invoiceId = invoiceResult.rows[0].id;
// Copy items to invoice
for (let i = 0; i < itemsResult.rows.length; i++) {
const item = itemsResult.rows[i];
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[invoiceId, item.quantity, item.description, item.rate, item.amount, i]
);
}
await client.query('COMMIT');
res.json(invoiceResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
console.error('Error converting quote to invoice:', error);
res.status(500).json({ error: 'Error converting quote to invoice' });
} finally {
client.release();
}
});
app.put('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
const { customer_id, invoice_date, terms, auth_code, tax_exempt, items } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Calculate totals
let subtotal = 0;
for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
subtotal += amount;
}
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
// Update invoice
await client.query(
`UPDATE invoices SET customer_id = $1, invoice_date = $2, terms = $3, auth_code = $4, tax_exempt = $5,
tax_rate = $6, subtotal = $7, tax_amount = $8, total = $9, updated_at = CURRENT_TIMESTAMP
WHERE id = $10`,
[customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, id]
);
// Delete old items and insert new ones
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
);
}
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error updating invoice:', error);
res.status(500).json({ error: 'Error updating invoice' });
} finally {
client.release();
}
});
app.delete('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
await client.query('DELETE FROM invoices WHERE id = $1', [id]);
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error deleting invoice:', error);
res.status(500).json({ error: 'Error deleting invoice' });
} finally {
client.release();
}
});
// PDF Generation for Quotes
app.get('/api/quotes/:id/pdf', async (req, res) => {
const { id } = req.params;
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
try {
console.log('[PDF] Fetching quote from database...');
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1
`, [id]);
if (quoteResult.rows.length === 0) {
console.log('[PDF] Quote not found');
return res.status(404).json({ error: 'Quote not found' });
}
const quote = quoteResult.rows[0];
console.log(`[PDF] Quote loaded: ${quote.quote_number}`);
console.log('[PDF] Fetching quote items...');
const itemsResult = await pool.query(
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
[id]
);
console.log(`[PDF] Items loaded: ${itemsResult.rows.length} items`);
console.log('[PDF] Generating HTML...');
const html = await generateQuotePDFHTML(quote, itemsResult.rows);
console.log(`[PDF] HTML generated, length: ${html.length} chars`);
console.log('[PDF] Launching Puppeteer...');
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--no-zygote',
'--single-process' // Wichtig für Docker!
],
protocolTimeout: 180000, // 3 Minuten
timeout: 180000
});
console.log('[PDF] Browser launched successfully');
console.log('[PDF] Creating new page...');
const page = await browser.newPage();
console.log('[PDF] Page created');
console.log('[PDF] Setting content...');
await page.setContent(html, {
waitUntil: 'networkidle0',
timeout: 60000
});
console.log('[PDF] Content set');
console.log('[PDF] Generating PDF...');
const pdf = await page.pdf({
format: 'Letter',
printBackground: true,
margin: {
top: '0.5in',
right: '0.5in',
bottom: '0.5in',
left: '0.5in'
},
timeout: 60000
});
console.log(`[PDF] PDF generated, size: ${pdf.length} bytes`);
console.log('[PDF] Closing browser...');
await browser.close();
console.log('[PDF] Browser closed');
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"`
});
res.end(pdf, 'binary');
console.log('[PDF] PDF sent to client successfully');
} catch (error) {
console.error('[PDF] ERROR:', error);
console.error('[PDF] Stack:', error.stack);
res.status(500).json({ error: 'Error generating PDF', details: error.message });
}
});
// PDF Generation for Invoices
app.get('/api/invoices/:id/pdf', async (req, res) => {
const { id } = req.params;
console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`);
try {
console.log('[INVOICE-PDF] Fetching invoice from database...');
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [id]);
if (invoiceResult.rows.length === 0) {
console.log('[INVOICE-PDF] Invoice not found');
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invoiceResult.rows[0];
console.log(`[INVOICE-PDF] Invoice loaded: ${invoice.invoice_number}`);
console.log('[INVOICE-PDF] Fetching invoice items...');
const itemsResult = await pool.query(
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
[id]
);
console.log(`[INVOICE-PDF] Items loaded: ${itemsResult.rows.length} items`);
console.log('[INVOICE-PDF] Generating HTML...');
const html = await generateInvoicePDFHTML(invoice, itemsResult.rows);
console.log(`[INVOICE-PDF] HTML generated, length: ${html.length} chars`);
console.log('[INVOICE-PDF] Launching Puppeteer...');
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--no-zygote',
'--single-process'
],
protocolTimeout: 180000,
timeout: 180000
});
console.log('[INVOICE-PDF] Browser launched successfully');
console.log('[INVOICE-PDF] Creating new page...');
const page = await browser.newPage();
console.log('[INVOICE-PDF] Page created');
console.log('[INVOICE-PDF] Setting content...');
await page.setContent(html, {
waitUntil: 'networkidle0',
timeout: 60000
});
console.log('[INVOICE-PDF] Content set');
console.log('[INVOICE-PDF] Generating PDF...');
const pdf = await page.pdf({
format: 'Letter',
printBackground: true,
margin: {
top: '0.5in',
right: '0.5in',
bottom: '0.5in',
left: '0.5in'
},
timeout: 60000
});
console.log(`[INVOICE-PDF] PDF generated, size: ${pdf.length} bytes`);
console.log('[INVOICE-PDF] Closing browser...');
await browser.close();
console.log('[INVOICE-PDF] Browser closed');
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="Invoice-${invoice.invoice_number}.pdf"`
});
res.end(pdf, 'binary');
console.log('[INVOICE-PDF] PDF sent to client successfully');
} catch (error) {
console.error('[INVOICE-PDF] ERROR:', error);
console.error('[INVOICE-PDF] Stack:', error.stack);
res.status(500).json({ error: 'Error generating PDF', details: error.message });
}
});
async function generateQuotePDFHTML(quote, items) {
// Check if logo exists
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
let logoHTML = '';
try {
const logoData = await fs.readFile(logoPath);
const logoBase64 = logoData.toString('base64');
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
} catch (error) {
// No logo, continue without it
}
// Generate items HTML
let itemsHTML = items.map(item => `
<tr>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${item.rate}</td>
<td class="amount">${item.amount}</td>
</tr>
`).join('');
// Add totals
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
</tr>`;
if (!quote.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%):</td>
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
</tr>`;
}
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">TOTAL:</td>
<td class="total-amount">$${parseFloat(quote.total).toFixed(2)}</td>
</tr>
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
// TBD note if applicable
let tbdNote = '';
if (quote.has_tbd) {
tbdNote = '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.</em></p>';
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.container {
max-width: 8.5in;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #333;
}
.company-info {
display: flex;
align-items: flex-start;
gap: 15px;
}
.logo {
width: 50px;
height: 50px;
}
.company-details h1 {
font-size: 16px;
font-weight: normal;
margin-bottom: 2px;
}
.company-details p {
font-size: 14px;
line-height: 1.4;
}
.tagline {
text-align: right;
font-style: italic;
font-size: 14px;
margin-bottom: 20px;
}
.contact-info {
text-align: right;
font-size: 13px;
line-height: 1.6;
}
.bill-to-section {
display: flex;
justify-content: space-between;
margin: 30px 0 60px 0;
}
.bill-to {
flex: 1;
}
.bill-to-label {
font-weight: bold;
margin-bottom: 5px;
}
.bill-to-address {
font-size: 14px;
line-height: 1.5;
}
.info-table {
border-collapse: collapse;
font-size: 13px;
margin-left: auto;
}
.info-table th {
background-color: #fff;
border: 1px solid #000;
padding: 8px;
font-weight: bold;
text-align: center;
}
.info-table td {
border: 1px solid #000;
padding: 8px;
text-align: center;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 13px;
}
.items-table th {
background-color: #fff;
border: 1px solid #000;
padding: 8px;
font-weight: bold;
text-align: center;
}
.items-table td {
border: 1px solid #000;
padding: 10px;
vertical-align: top;
}
.items-table td.qty {
text-align: center;
width: 60px;
}
.items-table td.description {
text-align: left;
}
.items-table td.description ul,
.items-table td.description ol {
margin: 5px 0;
padding-left: 20px;
}
.items-table td.description li {
margin: 2px 0;
}
.items-table td.description p {
margin: 5px 0;
}
.items-table td.description strong {
font-weight: bold;
}
.items-table td.description em {
font-style: italic;
}
.items-table td.description u {
text-decoration: underline;
}
.items-table td.rate,
.items-table td.amount {
text-align: right;
width: 100px;
}
.footer-row td {
border: 1px solid #000;
padding: 10px;
}
.total-label {
text-align: right;
font-size: 18px;
font-weight: bold;
padding-right: 20px !important;
}
.total-amount {
text-align: right;
font-size: 18px;
font-weight: bold;
}
.thank-you {
font-size: 13px;
}
.logo-size{
height: 40px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="company-info">
${logoHTML}
<div class="company-details">
<h1>Bay Area Affiliates, Inc.</h1>
<p>1001 Blucher Street<br>
Corpus Christi, Texas 78401</p>
</div>
</div>
<div>
<div class="tagline">
<em>Providing IT Services and Support in South Texas Since 1996</em>
</div>
<div class="contact-info">
Phone:<br>
(361) 765-8400<br>
(361) 765-8401<br>
(361) 232-6578<br>
Email:<br>
support@bayarea-cc.com
</div>
</div>
</div>
<div class="bill-to-section">
<div class="bill-to">
<div class="bill-to-label">Quote For:</div>
<div class="bill-to-address">
${quote.customer_name}<br>
${quote.street}<br>
${quote.city}, ${quote.state} ${quote.zip_code}
</div>
</div>
<table class="info-table">
<thead>
<tr>
<th>QUOTE #</th>
<th>ACCOUNT NO.</th>
<th>DATE</th>
</tr>
</thead>
<tbody>
<tr>
<td>${quote.quote_number}</td>
<td>${quote.account_number || ''}</td>
<td>${formatDate(quote.quote_date)}</td>
</tr>
</tbody>
</table>
</div>
<table class="items-table">
<thead>
<tr>
<th>QTY</th>
<th>DESCRIPTION</th>
<th>RATE</th>
<th>AMOUNT</th>
</tr>
</thead>
<tbody>
${itemsHTML}
</tbody>
</table>
${tbdNote}
</div>
</body>
</html>`;
}
async function generateInvoicePDFHTML(invoice, items) {
// Check if logo exists
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
let logoHTML = '';
try {
const logoData = await fs.readFile(logoPath);
const logoBase64 = logoData.toString('base64');
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
} catch (error) {
// No logo, continue without it
}
// Generate items HTML
let itemsHTML = items.map(item => `
<tr>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${item.rate}</td>
<td class="amount">${item.amount}</td>
</tr>
`).join('');
// Add totals
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
</tr>`;
if (!invoice.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
</tr>`;
}
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">TOTAL:</td>
<td class="total-amount">$${parseFloat(invoice.total).toFixed(2)}</td>
</tr>
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
font-size: 12px;
color: #333;
}
.container {
max-width: 8.5in;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #333;
}
.company-info {
display: flex;
align-items: flex-start;
gap: 15px;
}
.logo {
width: 50px;
height: 50px;
}
.company-details h1 {
font-size: 16px;
font-weight: normal;
margin-bottom: 2px;
}
.company-details p {
font-size: 14px;
line-height: 1.4;
}
.tagline {
text-align: right;
font-style: italic;
font-size: 14px;
margin-bottom: 20px;
}
.contact-info {
text-align: right;
font-size: 13px;
line-height: 1.6;
}
.bill-to-section {
display: flex;
justify-content: space-between;
margin: 30px 0 60px 0;
}
.bill-to {
flex: 1;
}
.bill-to-label {
font-weight: bold;
margin-bottom: 5px;
}
.bill-to-address {
font-size: 14px;
line-height: 1.5;
}
.info-table {
border-collapse: collapse;
font-size: 13px;
margin-left: auto;
}
.info-table th {
background-color: #fff;
border: 1px solid #000;
padding: 8px;
font-weight: bold;
text-align: center;
}
.info-table td {
border: 1px solid #000;
padding: 8px;
text-align: center;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 13px;
}
.items-table th {
background-color: #fff;
border: 1px solid #000;
padding: 8px;
font-weight: bold;
text-align: center;
}
.items-table td {
border: 1px solid #000;
padding: 10px;
vertical-align: top;
}
.items-table td.qty {
text-align: center;
width: 60px;
}
.items-table td.description {
text-align: left;
}
.items-table td.description ul,
.items-table td.description ol {
margin: 5px 0;
padding-left: 20px;
}
.items-table td.description li {
margin: 2px 0;
}
.items-table td.description p {
margin: 5px 0;
}
.items-table td.description strong {
font-weight: bold;
}
.items-table td.description em {
font-style: italic;
}
.items-table td.description u {
text-decoration: underline;
}
.items-table td.rate,
.items-table td.amount {
text-align: right;
width: 100px;
}
.footer-row td {
border: 1px solid #000;
padding: 10px;
}
.total-label {
text-align: right;
font-size: 18px;
font-weight: bold;
padding-right: 20px !important;
}
.total-amount {
text-align: right;
font-size: 18px;
font-weight: bold;
}
.thank-you {
font-size: 13px;
}
.logo-size{
height: 40px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="company-info">
${logoHTML}
<div class="company-details">
<h1>Bay Area Affiliates, Inc.</h1>
<p>1001 Blucher Street<br>
Corpus Christi, Texas 78401</p>
</div>
</div>
<div>
<div class="tagline">
<em>Providing IT Services and Support in South Texas Since 1996</em>
</div>
<div class="contact-info">
Phone:<br>
(361) 765-8400<br>
(361) 765-8401<br>
(361) 232-6578<br>
Email:<br>
accounting@bayarea-cc.com
</div>
</div>
</div>
<div class="bill-to-section">
<div class="bill-to">
<div class="bill-to-label">Bill To:</div>
<div class="bill-to-address">
${invoice.customer_name}<br>
${invoice.street}<br>
${invoice.city}, ${invoice.state} ${invoice.zip_code}
</div>
</div>
<table class="info-table">
<thead>
<tr>
<th>INVOICE #</th>
<th>ACCOUNT NO.</th>
<th>DATE</th>
<th>TERMS</th>
</tr>
</thead>
<tbody>
<tr>
<td>${invoice.invoice_number}</td>
<td>${invoice.account_number || ''}</td>
<td>${formatDate(invoice.invoice_date)}</td>
<td>${invoice.terms}</td>
</tr>
</tbody>
</table>
</div>
${invoice.auth_code ? `<p style="margin-bottom: 20px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : ''}
<table class="items-table">
<thead>
<tr>
<th>QTY</th>
<th>DESCRIPTION</th>
<th>RATE</th>
<th>AMOUNT</th>
</tr>
</thead>
<tbody>
${itemsHTML}
</tbody>
</table>
</div>
</body>
</html>`;
}
// Start server
app.listen(PORT, () => {
console.log(`Quote System running on port ${PORT}`);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
await pool.end();
process.exit(0);
});