1250 lines
48 KiB
JavaScript
1250 lines
48 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 { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Global browser instance
|
|
let browser = null;
|
|
|
|
// Initialize browser on startup
|
|
async function initBrowser() {
|
|
if (!browser) {
|
|
console.log('[BROWSER] Launching persistent browser...');
|
|
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('[BROWSER] Browser launched and ready');
|
|
|
|
// Restart browser if it crashes
|
|
browser.on('disconnected', () => {
|
|
console.log('[BROWSER] Browser disconnected, restarting...');
|
|
browser = null;
|
|
initBrowser();
|
|
});
|
|
}
|
|
return browser;
|
|
}
|
|
|
|
// 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 },
|
|
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 result = await pool.query(
|
|
'SELECT MAX(CAST(invoice_number AS INTEGER)) as max_number FROM invoices WHERE invoice_number ~ \'^[0-9]+$\''
|
|
);
|
|
|
|
if (result.rows.length === 0 || result.rows[0].max_number === null) {
|
|
return '110508';
|
|
}
|
|
|
|
return String(parseInt(result.rows[0].max_number) + 1);
|
|
}
|
|
|
|
// 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' });
|
|
}
|
|
});
|
|
|
|
// POST /api/customers
|
|
app.post('/api/customers', async (req, res) => {
|
|
// line1 bis line4 statt street/pobox/suite
|
|
const {
|
|
name, line1, line2, line3, line4, city, state, zip_code,
|
|
account_number, email, phone, phone2, taxable
|
|
} = req.body;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`INSERT INTO customers
|
|
(name, line1, line2, line3, line4, city, state,
|
|
zip_code, account_number, email, phone, phone2, taxable)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
RETURNING *`,
|
|
[name, line1 || null, line2 || null, line3 || null, line4 || null,
|
|
city || null, state || null, zip_code || null,
|
|
account_number || null, email || null, phone || null, phone2 || null,
|
|
taxable !== undefined ? taxable : true]
|
|
);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
console.error('Error creating customer:', error);
|
|
res.status(500).json({ error: 'Error creating customer' });
|
|
}
|
|
});
|
|
|
|
// PUT /api/customers/:id
|
|
app.put('/api/customers/:id', async (req, res) => {
|
|
const { id } = req.params;
|
|
const {
|
|
name, line1, line2, line3, line4, city, state, zip_code,
|
|
account_number, email, phone, phone2, taxable
|
|
} = req.body;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`UPDATE customers
|
|
SET name = $1, line1 = $2, line2 = $3, line3 = $4, line4 = $5,
|
|
city = $6, state = $7, zip_code = $8, account_number = $9, email = $10,
|
|
phone = $11, phone2 = $12, taxable = $13, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $14
|
|
RETURNING *`,
|
|
[name, line1 || null, line2 || null, line3 || null, line4 || null,
|
|
city || null, state || null, zip_code || null,
|
|
account_number || null, email || null, phone || null, phone2 || null,
|
|
taxable !== undefined ? taxable : true, 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 {
|
|
// KORRIGIERT: c.line1...c.line4 statt c.street
|
|
const quoteResult = await pool.query(`
|
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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();
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
await client.query(
|
|
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
|
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
|
);
|
|
}
|
|
|
|
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');
|
|
|
|
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;
|
|
|
|
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]
|
|
);
|
|
|
|
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, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
|
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
|
);
|
|
}
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// IMPORTANT: This must come BEFORE /api/invoices/:id to avoid route collision
|
|
app.get('/api/invoices/next-number', async (req, res) => {
|
|
try {
|
|
const nextNumber = await getNextInvoiceNumber();
|
|
res.json({ next_number: nextNumber });
|
|
} catch (error) {
|
|
console.error('Error getting next invoice number:', error);
|
|
res.status(500).json({ error: 'Error getting next invoice number' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/invoices/:id', async (req, res) => {
|
|
const { id } = req.params;
|
|
try {
|
|
// KORRIGIERT: c.line1, c.line2, c.line3, c.line4 statt c.street
|
|
const invoiceResult = await pool.query(`
|
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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 { invoice_number, 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');
|
|
|
|
// Validate invoice_number is provided and is numeric
|
|
if (!invoice_number || !/^\d+$/.test(invoice_number)) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
|
}
|
|
|
|
// Check if invoice number already exists
|
|
const existingInvoice = await client.query(
|
|
'SELECT id FROM invoices WHERE invoice_number = $1',
|
|
[invoice_number]
|
|
);
|
|
|
|
if (existingInvoice.rows.length > 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
|
}
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
await client.query(
|
|
// qbo_item_id hinzugefügt
|
|
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
|
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] // Default '9' (Parts)
|
|
);
|
|
}
|
|
|
|
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');
|
|
|
|
// KORRIGIERT: c.line1...c.line4 statt c.street
|
|
const quoteResult = await pool.query(`
|
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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];
|
|
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
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.' });
|
|
}
|
|
|
|
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;
|
|
|
|
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, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
|
[invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
|
);
|
|
}
|
|
|
|
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 { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items } = req.body;
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Validate invoice_number is provided and is numeric
|
|
if (!invoice_number || !/^\d+$/.test(invoice_number)) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
|
}
|
|
|
|
// Check if invoice number already exists (excluding current invoice)
|
|
const existingInvoice = await client.query(
|
|
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
|
|
[invoice_number, id]
|
|
);
|
|
|
|
if (existingInvoice.rows.length > 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
|
}
|
|
|
|
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;
|
|
|
|
await client.query(
|
|
`UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6,
|
|
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $11`,
|
|
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, id]
|
|
);
|
|
|
|
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, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
|
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
|
);
|
|
}
|
|
|
|
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 code continues below...
|
|
|
|
// PDF Generation using templates and persistent browser
|
|
app.get('/api/quotes/:id/pdf', async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
|
|
|
|
try {
|
|
// KORRIGIERT: Abfrage von line1-4 statt street/pobox
|
|
const quoteResult = await pool.query(`
|
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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 quote = quoteResult.rows[0];
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
const templatePath = path.join(__dirname, 'templates', 'quote-template.html');
|
|
let html = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
let logoHTML = '';
|
|
try {
|
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
|
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 (err) {}
|
|
|
|
// Items HTML generieren
|
|
let itemsHTML = itemsResult.rows.map(item => {
|
|
let rateFormatted = item.rate;
|
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
|
}
|
|
return `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
// 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>`;
|
|
|
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
|
|
|
// --- ADRESS-LOGIK (NEU) ---
|
|
const addressLines = [];
|
|
// Wenn line1 existiert UND ungleich dem Namen ist, hinzufügen. Sonst überspringen (da Name eh drüber steht).
|
|
if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) {
|
|
addressLines.push(quote.line1);
|
|
}
|
|
if (quote.line2) addressLines.push(quote.line2);
|
|
if (quote.line3) addressLines.push(quote.line3);
|
|
if (quote.line4) addressLines.push(quote.line4);
|
|
|
|
const streetBlock = addressLines.join('<br>');
|
|
|
|
// Ersetzen
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock) // Hier kommt der Block rein
|
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
|
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
|
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
|
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
|
.replace('{{ITEMS}}', itemsHTML)
|
|
.replace('{{TBD_NOTE}}', tbdNote);
|
|
|
|
const browserInstance = await initBrowser();
|
|
const page = await browserInstance.newPage();
|
|
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
const pdf = await page.pdf({
|
|
format: 'Letter', printBackground: true,
|
|
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
|
|
});
|
|
await page.close();
|
|
|
|
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] Quote PDF sent successfully');
|
|
|
|
} catch (error) {
|
|
console.error('[PDF] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
|
}
|
|
});
|
|
|
|
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 {
|
|
// KORRIGIERT: Abfrage von line1-4
|
|
const invoiceResult = await pool.query(`
|
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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 invoice = invoiceResult.rows[0];
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
|
|
let html = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
let logoHTML = '';
|
|
try {
|
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
|
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 (err) {}
|
|
|
|
let itemsHTML = itemsResult.rows.map(item => {
|
|
let rateFormatted = item.rate;
|
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
|
}
|
|
return `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
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>`;
|
|
|
|
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
|
|
|
// --- ADRESS-LOGIK (NEU) ---
|
|
const addressLines = [];
|
|
if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) {
|
|
addressLines.push(invoice.line1);
|
|
}
|
|
if (invoice.line2) addressLines.push(invoice.line2);
|
|
if (invoice.line3) addressLines.push(invoice.line3);
|
|
if (invoice.line4) addressLines.push(invoice.line4);
|
|
|
|
const streetBlock = addressLines.join('<br>');
|
|
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
|
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
|
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number)
|
|
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
|
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
|
.replace('{{TERMS}}', invoice.terms)
|
|
.replace('{{AUTHORIZATION}}', authHTML)
|
|
.replace('{{ITEMS}}', itemsHTML);
|
|
|
|
const browserInstance = await initBrowser();
|
|
const page = await browserInstance.newPage();
|
|
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
const pdf = await page.pdf({
|
|
format: 'Letter', printBackground: true,
|
|
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
|
|
});
|
|
await page.close();
|
|
|
|
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] Invoice PDF sent successfully');
|
|
|
|
} catch (error) {
|
|
console.error('[INVOICE-PDF] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// HTML Debug Endpoints
|
|
app.get('/api/quotes/:id/html', async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
// KORREKTUR: Line 1-4 abfragen
|
|
const quoteResult = await pool.query(`
|
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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 quote = quoteResult.rows[0];
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
const templatePath = path.join(__dirname, 'templates', 'quote-template.html');
|
|
let html = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
let logoHTML = '';
|
|
try {
|
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
|
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 (err) {}
|
|
|
|
let itemsHTML = itemsResult.rows.map(item => {
|
|
let rateFormatted = item.rate;
|
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
|
}
|
|
return `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
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>`;
|
|
|
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
|
|
|
// --- ADRESS LOGIK ---
|
|
const addressLines = [];
|
|
if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) {
|
|
addressLines.push(quote.line1);
|
|
}
|
|
if (quote.line2) addressLines.push(quote.line2);
|
|
if (quote.line3) addressLines.push(quote.line3);
|
|
if (quote.line4) addressLines.push(quote.line4);
|
|
|
|
const streetBlock = addressLines.join('<br>');
|
|
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
|
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
|
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
|
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
|
.replace('{{ITEMS}}', itemsHTML)
|
|
.replace('{{TBD_NOTE}}', tbdNote);
|
|
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(html);
|
|
|
|
} catch (error) {
|
|
console.error('[HTML] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating HTML' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/invoices/:id/html', async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
// KORREKTUR: Line 1-4 abfragen
|
|
const invoiceResult = await pool.query(`
|
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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 invoice = invoiceResult.rows[0];
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
|
|
let html = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
let logoHTML = '';
|
|
try {
|
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
|
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 (err) {}
|
|
|
|
let itemsHTML = itemsResult.rows.map(item => {
|
|
let rateFormatted = item.rate;
|
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
|
}
|
|
return `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
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>`;
|
|
|
|
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
|
|
|
// --- ADRESS LOGIK ---
|
|
const addressLines = [];
|
|
if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) {
|
|
addressLines.push(invoice.line1);
|
|
}
|
|
if (invoice.line2) addressLines.push(invoice.line2);
|
|
if (invoice.line3) addressLines.push(invoice.line3);
|
|
if (invoice.line4) addressLines.push(invoice.line4);
|
|
|
|
const streetBlock = addressLines.join('<br>');
|
|
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
|
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
|
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number)
|
|
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
|
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
|
.replace('{{TERMS}}', invoice.terms)
|
|
.replace('{{AUTHORIZATION}}', authHTML)
|
|
.replace('{{ITEMS}}', itemsHTML);
|
|
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(html);
|
|
|
|
} catch (error) {
|
|
console.error('[HTML] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating HTML' });
|
|
}
|
|
});
|
|
|
|
// QBO Export Endpoint
|
|
app.post('/api/invoices/:id/export', async (req, res) => {
|
|
const { id } = req.params;
|
|
const client = await pool.connect();
|
|
|
|
// HIER SIND DEINE FESTEN IDs
|
|
const QBO_LABOR_ID = '5'; // Labor:Labor
|
|
const QBO_PARTS_ID = '9'; // Parts:Parts
|
|
|
|
try {
|
|
// 1. Lokale Rechnung laden
|
|
const invoiceRes = await client.query(`
|
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
|
FROM invoices i
|
|
LEFT JOIN customers c ON i.customer_id = c.id
|
|
WHERE i.id = $1
|
|
`, [id]);
|
|
|
|
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
|
const invoice = invoiceRes.rows[0];
|
|
|
|
if (!invoice.customer_qbo_id) {
|
|
return res.status(400).json({ error: `Kunde "${invoice.customer_name}" ist noch nicht mit QBO verknüpft.` });
|
|
}
|
|
|
|
// 2. Items laden (inkl. qbo_item_id)
|
|
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
|
|
const items = itemsRes.rows;
|
|
|
|
// 3. QBO Client
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
|
? 'https://quickbooks.api.intuit.com'
|
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
|
|
|
// 4. QBO JSON bauen (OHNE "select * from Item...")
|
|
const lineItems = items.map(item => {
|
|
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
|
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
|
|
|
// WICHTIG: Hier nutzen wir die ID aus der Datenbank oder den Fallback
|
|
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
|
|
|
return {
|
|
"DetailType": "SalesItemLineDetail",
|
|
"Amount": amount,
|
|
"Description": item.description,
|
|
"SalesItemLineDetail": {
|
|
"ItemRef": {
|
|
"value": itemRefId,
|
|
"name": itemRefName
|
|
},
|
|
"UnitPrice": rate,
|
|
"Qty": parseFloat(item.quantity) || 1
|
|
}
|
|
};
|
|
});
|
|
|
|
const qboInvoicePayload = {
|
|
"CustomerRef": { "value": invoice.customer_qbo_id },
|
|
"DocNumber": invoice.invoice_number,
|
|
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
|
"Line": lineItems,
|
|
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
|
|
};
|
|
|
|
console.log(`📤 Sende Rechnung ${invoice.invoice_number} an QBO...`);
|
|
|
|
const createResponse = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(qboInvoicePayload)
|
|
});
|
|
|
|
const qboInvoice = createResponse.getJson ? createResponse.getJson() : createResponse.json;
|
|
onsole.log("🔍 FULL QBO RESPONSE:", JSON.stringify(qboInvoice, null, 2));
|
|
if (!qboInvoice.Id) {
|
|
throw new Error("QBO hat keine ID zurückgegeben. Wahrscheinlich ein Fehler im Request (siehe Logs).");
|
|
}
|
|
console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}`);
|
|
|
|
await client.query(
|
|
`UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3 WHERE id = $4`,
|
|
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, id]
|
|
);
|
|
|
|
res.json({ success: true, qbo_id: qboInvoice.Id });
|
|
|
|
} catch (error) {
|
|
console.error("QBO Export Error:", error);
|
|
let errorDetails = error.message;
|
|
if (error.response?.data?.Fault?.Error?.[0]) {
|
|
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
|
|
}
|
|
res.status(500).json({ error: "QBO Export failed: " + errorDetails });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// Start server and browser
|
|
async function startServer() {
|
|
await initBrowser();
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Quote System running on port ${PORT}`);
|
|
});
|
|
}
|
|
|
|
startServer();
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', async () => {
|
|
if (browser) {
|
|
await browser.close();
|
|
}
|
|
await pool.end();
|
|
process.exit(0);
|
|
}); |