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 OAuthClient = require('intuit-oauth');
const { makeQboApiCall, getOAuthClient, saveTokens, resetOAuthClient } = 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, scheduled_send_date } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// invoice_number ist jetzt OPTIONAL — wird erst beim QBO Export vergeben
// Wenn angegeben, muss sie numerisch sein und darf nicht existieren
if (invoice_number && invoice_number.trim() !== '') {
if (!/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
}
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;
// invoice_number kann NULL sein
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
const sendDate = scheduled_send_date || null;
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, scheduled_send_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, sendDate]
);
const invoiceId = invoiceResult.rows[0].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)',
[invoiceId, 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(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 = null;
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, scheduled_send_date } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// invoice_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
if (invNum && !/^\d+$/.test(invNum)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
}
if (invNum) {
const existingInvoice = await client.query(
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
[invNum, id]
);
if (existingInvoice.rows.length > 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invNum} 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 sendDate = scheduled_send_date || null;
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, scheduled_send_date = $11, updated_at = CURRENT_TIMESTAMP
WHERE id = $12`,
[invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, sendDate, 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 = ``;
} 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 `
* Note: This quote contains items marked as "TBD". The final total may vary.
' : ''; // --- 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('Authorization: ${invoice.auth_code}
` : ''; // --- 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('* Note: This quote contains items marked as "TBD". The final total may vary.
' : ''; // --- 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('Authorization: ${invoice.auth_code}
` : ''; // --- 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('${e.message || e}
Zurück zur App `); } }); // Status-Check Endpoint (für die UI) app.get('/api/qbo/status', (req, res) => { try { const client = getOAuthClient(); const token = client.getToken(); const hasToken = !!(token && token.refresh_token); res.json({ connected: hasToken, realmId: token?.realmId || null }); } catch (e) { res.json({ connected: false }); } }); app.post('/api/qbo/import-unpaid', async (req, res) => { const dbClient = await pool.connect(); try { 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'; // 1. Alle unbezahlten Rechnungen aus QBO holen // Balance > '0' = noch nicht vollständig bezahlt // MAXRESULTS 1000 = sicherheitshalber hoch setzen console.log('📥 QBO Import: Lade unbezahlte Rechnungen...'); const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; const qboInvoices = data.QueryResponse?.Invoice || []; console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`); if (qboInvoices.length === 0) { return res.json({ success: true, imported: 0, skipped: 0, skippedNoCustomer: 0, message: 'Keine unbezahlten Rechnungen in QBO gefunden.' }); } // 2. Lokale Kunden laden (die mit QBO verknüpft sind) const customersResult = await dbClient.query( 'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL' ); const customerMap = new Map(); customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c)); // 3. Bereits importierte QBO-Rechnungen ermitteln (nach qbo_id) const existingResult = await dbClient.query( 'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL' ); const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id)); // 4. Import durchführen let imported = 0; let skipped = 0; let skippedNoCustomer = 0; const skippedCustomerNames = []; await dbClient.query('BEGIN'); for (const qboInv of qboInvoices) { const qboId = String(qboInv.Id); // Bereits importiert? → Überspringen if (existingQboIds.has(qboId)) { skipped++; continue; } // Kunde lokal vorhanden? const customerQboId = String(qboInv.CustomerRef?.value || ''); const localCustomer = customerMap.get(customerQboId); if (!localCustomer) { skippedNoCustomer++; const custName = qboInv.CustomerRef?.name || 'Unbekannt'; if (!skippedCustomerNames.includes(custName)) { skippedCustomerNames.push(custName); } continue; } // Werte aus QBO-Rechnung extrahieren const docNumber = qboInv.DocNumber || ''; const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0]; const syncToken = qboInv.SyncToken || ''; // Terms aus QBO mappen (SalesTermRef) let terms = 'Net 30'; if (qboInv.SalesTermRef?.name) { terms = qboInv.SalesTermRef.name; } // Tax: Prüfen ob TaxLine vorhanden const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0; const taxExempt = taxAmount === 0; // Subtotal berechnen (Total - Tax) const total = parseFloat(qboInv.TotalAmt) || 0; const subtotal = total - taxAmount; const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25; // Memo als auth_code (falls vorhanden) const authCode = qboInv.CustomerMemo?.value || ''; // Rechnung einfügen const invoiceResult = await dbClient.query( `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, qbo_id, qbo_sync_token, qbo_doc_number) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [docNumber, localCustomer.id, txnDate, terms, authCode, taxExempt, taxRate, subtotal, taxAmount, total, qboId, syncToken, docNumber] ); const localInvoiceId = invoiceResult.rows[0].id; // Line Items importieren const lines = qboInv.Line || []; let itemOrder = 0; for (const line of lines) { // Nur SalesItemLineDetail-Zeilen (keine SubTotalLine etc.) if (line.DetailType !== 'SalesItemLineDetail') continue; const detail = line.SalesItemLineDetail || {}; const qty = String(detail.Qty || 1); const rate = String(detail.UnitPrice || 0); const amount = String(line.Amount || 0); const description = line.Description || ''; // Item-Typ ermitteln (Labor=5, Parts=9) const itemRefValue = detail.ItemRef?.value || '9'; const itemRefName = (detail.ItemRef?.name || '').toLowerCase(); let qboItemId = '9'; // Default: Parts if (itemRefValue === '5' || itemRefName.includes('labor')) { qboItemId = '5'; } await dbClient.query( `INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId] ); itemOrder++; } imported++; console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`); } await dbClient.query('COMMIT'); const message = [ `${imported} Rechnungen importiert.`, skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '', skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : '' ].filter(Boolean).join(' '); console.log(`📥 QBO Import abgeschlossen: ${message}`); res.json({ success: true, imported, skipped, skippedNoCustomer, skippedCustomerNames, message }); } catch (error) { await dbClient.query('ROLLBACK'); console.error('❌ QBO Import Error:', error); res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message }); } finally { dbClient.release(); } }); // Mark invoice as paid app.patch('/api/invoices/:id/mark-paid', async (req, res) => { const { id } = req.params; const { paid_date } = req.body; // Optional: explizites Datum, sonst heute try { const dateToUse = paid_date || new Date().toISOString().split('T')[0]; const result = await pool.query( 'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *', [dateToUse, id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } console.log(`💰 Invoice #${result.rows[0].invoice_number} als bezahlt markiert (${dateToUse})`); res.json(result.rows[0]); } catch (error) { console.error('Error marking invoice as paid:', error); res.status(500).json({ error: 'Error marking invoice as paid' }); } }); // Mark invoice as unpaid app.patch('/api/invoices/:id/mark-unpaid', async (req, res) => { const { id } = req.params; try { const result = await pool.query( 'UPDATE invoices SET paid_date = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *', [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } console.log(`↩️ Invoice #${result.rows[0].invoice_number} als unbezahlt markiert`); res.json(result.rows[0]); } catch (error) { console.error('Error marking invoice as unpaid:', error); res.status(500).json({ error: 'Error marking invoice as unpaid' }); } }); app.patch('/api/invoices/:id/reset-qbo', async (req, res) => { const { id } = req.params; try { const result = await pool.query( `UPDATE invoices SET qbo_id = NULL, qbo_sync_token = NULL, qbo_doc_number = NULL, invoice_number = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *`, [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } console.log(`🔄 Invoice ID ${id} QBO-Verknüpfung zurückgesetzt`); res.json(result.rows[0]); } catch (error) { console.error('Error resetting QBO link:', error); res.status(500).json({ error: 'Error resetting QBO link' }); } }); // 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); });