invoice-system/server.js

2740 lines
110 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
// =====================================================
// INVOICE CREATE + UPDATE — Auto QBO Export/Sync
// ERSETZE POST /api/invoices und PUT /api/invoices/:id
// =====================================================
const QBO_LABOR_ID = '5';
const QBO_PARTS_ID = '9';
// --- Helper: QBO Invoice Export (create) ---
async function exportInvoiceToQbo(invoiceId, client) {
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
`, [invoiceId]);
const invoice = invoiceRes.rows[0];
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
const items = itemsRes.rows;
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';
// Nächste DocNumber ermitteln (aus lokaler DB)
const maxNumResult = await client.query(`
SELECT GREATEST(
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0),
COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
) as max_num
`);
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
const lineItems = items.map(item => {
const parseNum = (val) => {
if (val === null || val === undefined) return 0;
if (typeof val === 'number') return val;
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
};
const rate = parseNum(item.rate);
const qty = parseNum(item.quantity) || 1;
const amount = rate * qty;
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": qty
}
};
});
const qboPayload = {
"CustomerRef": { "value": invoice.customer_qbo_id },
"DocNumber": nextDocNumber,
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
"Line": lineItems,
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
"EmailStatus": "NotSet",
"BillEmail": { "Address": invoice.email || "" }
};
// Retry bei Duplicate
let qboInvoice = null;
for (let attempt = 0; attempt < 5; attempt++) {
console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`);
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboPayload)
});
const data = response.getJson ? response.getJson() : response.json;
if (data.Fault?.Error?.[0]?.code === '6140') {
console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`);
qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
continue;
}
if (data.Fault) {
const errMsg = data.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(data.Fault);
console.error(`❌ QBO Export Fault:`, errMsg);
throw new Error('QBO export failed: ' + errMsg);
}
qboInvoice = data.Invoice || data;
if (qboInvoice.Id) break;
throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500));
}
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
await client.query(
'UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5',
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, invoiceId]
);
console.log(`✅ QBO Invoice created: ID ${qboInvoice.Id}, DocNumber ${qboInvoice.DocNumber}`);
return { success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber };
}
// --- Helper: QBO Invoice Update (sync) ---
async function syncInvoiceToQbo(invoiceId, client) {
const invoiceRes = await client.query(`
SELECT i.*, c.qbo_id as customer_qbo_id
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [invoiceId]);
const invoice = invoiceRes.rows[0];
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' };
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
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';
// Aktuellen SyncToken holen
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const currentSyncToken = qboData.Invoice?.SyncToken;
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
const lineItems = itemsRes.rows.map(item => {
// Robust parsing: handle both string ("$1,250.00") and numeric types
const parseNum = (val) => {
if (val === null || val === undefined) return 0;
if (typeof val === 'number') return val;
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
};
const rate = parseNum(item.rate);
const qty = parseNum(item.quantity) || 1;
const amount = rate * qty; // Always compute amount = rate * qty for QBO
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
console.log(` 📋 Item: qty=${qty}, rate=${rate}, amount=${amount}, ref=${itemRefId}`);
return {
"DetailType": "SalesItemLineDetail",
"Amount": amount,
"Description": item.description,
"SalesItemLineDetail": {
"ItemRef": { "value": itemRefId, "name": itemRefName },
"UnitPrice": rate,
"Qty": parseFloat(item.quantity) || 1
}
};
});
const updatePayload = {
"Id": invoice.qbo_id,
"SyncToken": currentSyncToken,
"sparse": true,
"Line": lineItems,
"CustomerRef": { "value": invoice.customer_qbo_id },
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
};
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
const updateRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload)
});
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
// Prüfe auf Fault
if (updateData.Fault) {
const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault);
console.error(`❌ QBO Sync Fault:`, errMsg);
throw new Error('QBO sync failed: ' + errMsg);
}
const updated = updateData.Invoice || updateData;
if (!updated.Id) {
console.error(`❌ QBO unexpected response:`, JSON.stringify(updateData).substring(0, 500));
throw new Error('QBO update returned no ID');
}
await client.query(
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[updated.SyncToken, invoiceId]
);
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
return { success: true, sync_token: updated.SyncToken };
}
// =====================================================
// POST /api/invoices — Create + Auto QBO Export
// =====================================================
app.post('/api/invoices', async (req, res) => {
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, created_from_quote_id } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// invoice_number kann leer sein — wird von QBO vergeben
// Falls angegeben, validieren
if (invoice_number && !/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invoice number must be numeric.' });
}
// Temporäre Nummer falls leer
const tempNumber = invoice_number || `DRAFT-${Date.now()}`;
if (invoice_number) {
const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1', [invoice_number]);
if (existing.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, scheduled_send_date, created_from_quote_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, created_from_quote_id]
);
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');
// Auto QBO Export (falls Kunde in QBO)
let qboResult = null;
try {
qboResult = await exportInvoiceToQbo(invoiceId, client);
if (qboResult.skipped) {
console.log(` Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
}
} catch (qboErr) {
console.error(`⚠️ Auto QBO export failed for Invoice ${invoiceId}:`, qboErr.message);
// Nicht abbrechen — lokal wurde gespeichert
}
res.json({
...invoiceResult.rows[0],
qbo_id: qboResult?.qbo_id || null,
qbo_doc_number: qboResult?.qbo_doc_number || null
});
} catch (error) {
await client.query('ROLLBACK');
console.error('Error creating invoice:', error);
res.status(500).json({ error: 'Error creating invoice' });
} finally {
client.release();
}
});
// =====================================================
// PUT /api/invoices/:id — Update + Auto QBO Sync
// =====================================================
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-Nummer validieren (falls angegeben)
if (invoice_number && !/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invoice number must be numeric.' });
}
if (invoice_number) {
const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', [invoice_number, id]);
if (existing.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;
// Update lokal — invoice_number nur ändern wenn angegeben
if (invoice_number) {
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`,
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, id]
);
} else {
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, scheduled_send_date = $10, updated_at = CURRENT_TIMESTAMP
WHERE id = $11`,
[customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, id]
);
}
// Items neu schreiben
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');
// Auto QBO: Export if not yet in QBO, Sync if already in QBO
let qboResult = null;
try {
// Prüfe ob Invoice schon in QBO
const checkRes = await client.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
const hasQboId = !!checkRes.rows[0]?.qbo_id;
if (hasQboId) {
qboResult = await syncInvoiceToQbo(id, client);
} else {
qboResult = await exportInvoiceToQbo(id, client);
}
if (qboResult.skipped) {
console.log(` Invoice ${id}: ${qboResult.reason}`);
}
} catch (qboErr) {
console.error(`⚠️ Auto QBO failed for Invoice ${id}:`, qboErr.message);
}
res.json({ success: true, qbo_synced: !!qboResult?.success, qbo_id: qboResult?.qbo_id || null, qbo_doc_number: qboResult?.qbo_doc_number || null });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error updating invoice:', error);
res.status(500).json({ error: 'Error updating invoice' });
} finally {
client.release();
}
});
// 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) => {
const {
name, contact, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable, remarks
} = req.body;
try {
const result = await pool.query(
`INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`,
[name, contact || null, 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, remarks || null]
);
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, contact, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable, remarks
} = req.body;
try {
const result = await pool.query(
`UPDATE customers
SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6,
city = $7, state = $8, zip_code = $9, account_number = $10, email = $11,
phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP
WHERE id = $16
RETURNING *`,
[name, contact || null, 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, remarks || null, id]
);
const customer = result.rows[0];
// QBO Update
if (customer.qbo_id) {
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';
// SyncToken holen
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Customer?.SyncToken;
if (syncToken !== undefined) {
const updatePayload = {
Id: customer.qbo_id,
SyncToken: syncToken,
sparse: true,
DisplayName: name,
CompanyName: name,
PrimaryEmailAddr: email ? { Address: email } : undefined,
PrimaryPhone: phone ? { FreeFormNumber: phone } : undefined,
Taxable: taxable !== false,
Notes: remarks || undefined
};
// Contact → GivenName / FamilyName
if (contact) {
const parts = contact.trim().split(/\s+/);
if (parts.length >= 2) {
updatePayload.GivenName = parts[0];
updatePayload.FamilyName = parts.slice(1).join(' ');
} else {
updatePayload.GivenName = parts[0];
}
}
// Adresse
const addr = {};
if (line1) addr.Line1 = line1;
if (line2) addr.Line2 = line2;
if (line3) addr.Line3 = line3;
if (line4) addr.Line4 = line4;
if (city) addr.City = city;
if (state) addr.CountrySubDivisionCode = state;
if (zip_code) addr.PostalCode = zip_code;
if (Object.keys(addr).length > 0) updatePayload.BillAddr = addr;
console.log(`📤 Updating QBO Customer ${customer.qbo_id} (${name})...`);
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload)
});
console.log(`✅ QBO Customer ${customer.qbo_id} updated.`);
}
} catch (qboError) {
console.error(`⚠️ QBO update failed for Customer ${customer.qbo_id}:`, qboError.message);
}
}
res.json(customer);
} 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 {
// Kunde laden
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
if (custResult.rows.length === 0) {
return res.status(404).json({ error: 'Customer not found' });
}
const customer = custResult.rows[0];
// In QBO deaktivieren falls vorhanden
if (customer.qbo_id) {
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';
// SyncToken holen
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Customer?.SyncToken;
if (syncToken !== undefined) {
console.log(`🗑️ Deactivating QBO Customer ${customer.qbo_id} (${customer.name})...`);
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Id: customer.qbo_id,
SyncToken: syncToken,
sparse: true,
Active: false
})
});
console.log(`✅ QBO Customer ${customer.qbo_id} deactivated.`);
}
} catch (qboError) {
console.error(`⚠️ QBO deactivate failed for Customer ${customer.qbo_id}:`, qboError.message);
// Trotzdem lokal löschen
}
}
// Lokal löschen
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, c.qbo_id as customer_qbo_id,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
ORDER BY i.created_at DESC
`);
const rows = result.rows.map(r => ({
...r,
amount_paid: parseFloat(r.amount_paid) || 0,
balance: (parseFloat(r.total) || 0) - (parseFloat(r.amount_paid) || 0)
}));
res.json(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 {
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
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];
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid;
const itemsResult = await pool.query(
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
[id]
);
res.json({ invoice, items: itemsResult.rows });
} catch (error) {
console.error('Error fetching invoice:', error);
res.status(500).json({ error: 'Error fetching invoice' });
}
});
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.delete('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Invoice laden um qbo_id zu prüfen
const invResult = await client.query('SELECT qbo_id, qbo_sync_token, invoice_number FROM invoices WHERE id = $1', [id]);
if (invResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invResult.rows[0];
// In QBO löschen falls vorhanden
if (invoice.qbo_id) {
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';
// Aktuellen SyncToken aus QBO holen (sicherste Methode)
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Invoice?.SyncToken;
if (syncToken !== undefined) {
// QBO Invoice "voiden" (nicht löschen — QBO empfiehlt Void statt Delete)
// Void setzt Balance auf 0 und markiert als nichtig
console.log(`🗑️ Voiding QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.invoice_number})...`);
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice?operation=void`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Id: invoice.qbo_id,
SyncToken: syncToken
})
});
console.log(`✅ QBO Invoice ${invoice.qbo_id} voided.`);
}
} catch (qboError) {
// QBO-Fehler loggen aber lokales Löschen trotzdem durchführen
console.error(`⚠️ QBO void failed for Invoice ${invoice.qbo_id}:`, qboError.message);
// Nicht abbrechen — lokal trotzdem löschen
}
}
// Lokal löschen (payment_invoices hat ON DELETE CASCADE)
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
await client.query('DELETE FROM payment_invoices 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();
}
});
app.patch('/api/invoices/:id/email-status', async (req, res) => {
const { id } = req.params;
const { status } = req.body; // 'sent' or 'open'
if (!['sent', 'open'].includes(status)) {
return res.status(400).json({ error: 'Status must be "sent" or "open".' });
}
try {
const invResult = await pool.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
if (invResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
const invoice = invResult.rows[0];
// QBO updaten falls vorhanden
if (invoice.qbo_id) {
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';
// SyncToken holen
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Invoice?.SyncToken;
if (syncToken !== undefined) {
const emailStatus = status === 'sent' ? 'EmailSent' : 'NotSet';
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Id: invoice.qbo_id,
SyncToken: syncToken,
sparse: true,
EmailStatus: emailStatus
})
});
console.log(`✅ QBO Invoice ${invoice.qbo_id} email status → ${emailStatus}`);
}
}
// Lokal updaten
await pool.query(
'UPDATE invoices SET email_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[status, id]
);
res.json({ success: true, status });
} catch (error) {
console.error('Error updating email status:', error);
res.status(500).json({ error: 'Failed to update status: ' + error.message });
}
});
// 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();
// IDs für deine Items (Labor / Parts)
const QBO_LABOR_ID = '5';
const QBO_PARTS_ID = '9';
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
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
const items = itemsRes.rows;
// 3. QBO Client vorbereiten
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';
// --- SCHRITT 3a: NÄCHSTE NUMMER ERMITTELN (lokal) ---
// Wir nehmen das Maximum aus qbo_doc_number UND invoice_number
const maxNumResult = await client.query(`
SELECT GREATEST(
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0),
COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
) as max_num
`);
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
console.log(`✅ Nächste Nummer (aus lokaler DB): ${nextDocNumber}`);
// 4. QBO JSON bauen
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;
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": nextDocNumber,
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
"Line": lineItems,
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
"EmailStatus": "EmailSent",
"BillEmail": { "Address": invoice.email || "" }
};
// 5. An QBO senden — mit Retry bei Duplicate
let qboInvoice = null;
const MAX_RETRIES = 5;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
console.log(`📤 Sende Rechnung an QBO (DocNumber: ${qboInvoicePayload.DocNumber})...`);
const createResponse = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboInvoicePayload)
});
const responseData = createResponse.getJson ? createResponse.getJson() : createResponse.json;
// Prüfe auf Duplicate Error (code 6140)
if (responseData.Fault?.Error?.[0]?.code === '6140') {
const oldNum = parseInt(qboInvoicePayload.DocNumber);
qboInvoicePayload.DocNumber = (oldNum + 1).toString();
console.log(`⚠️ DocNumber ${oldNum} existiert bereits. Versuche ${qboInvoicePayload.DocNumber}...`);
continue;
}
qboInvoice = responseData.Invoice || responseData;
if (qboInvoice.Id) {
break; // Erfolg!
} else {
console.error("FULL RESPONSE DUMP:", JSON.stringify(responseData, null, 2));
throw new Error("QBO hat keine ID zurückgegeben: " +
(responseData.Fault?.Error?.[0]?.Message || JSON.stringify(responseData)));
}
}
if (!qboInvoice || !qboInvoice.Id) {
throw new Error(`Konnte nach ${MAX_RETRIES} Versuchen keine freie DocNumber finden.`);
}
console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}, DocNumber: ${qboInvoice.DocNumber}`);
// 6. DB Update
await client.query(
`UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5`,
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, id]
);
res.json({ success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber });
} 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();
}
});
app.get('/api/qbo/overdue', async (req, res) => {
try {
// Datum vor 30 Tagen berechnen
const date = new Date();
date.setDate(date.getDate() - 30);
const dateStr = date.toISOString().split('T')[0];
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
// Query: Offene Rechnungen, deren Fälligkeitsdatum älter als 30 Tage ist
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
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';
// makeQboApiCall kümmert sich um den Refresh, falls nötig!
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const invoices = data.QueryResponse?.Invoice || [];
console.log(`${invoices.length} überfällige Rechnungen gefunden.`);
res.json(invoices);
} catch (error) {
console.error("QBO Report Error:", error);
res.status(500).json({ error: error.message });
}
});
// Schritt 1: User klickt "Authorize" → Redirect zu Intuit
app.get('/auth/qbo', (req, res) => {
const client = getOAuthClient();
const authUri = client.authorizeUri({
scope: [OAuthClient.scopes.Accounting],
state: 'intuit-qbo-auth'
});
console.log('🔗 Redirecting to QBO Authorization:', authUri);
res.redirect(authUri);
});
// Schritt 2: Intuit redirected zurück mit Code → Token holen
app.get('/auth/qbo/callback', async (req, res) => {
const client = getOAuthClient();
try {
const authResponse = await client.createToken(req.url);
console.log('✅ QBO Authorization erfolgreich!');
saveTokens();
// Redirect zurück zur App (Settings Tab)
res.redirect('/#settings');
} catch (e) {
console.error('❌ QBO Authorization fehlgeschlagen:', e);
res.status(500).send(`
<h2>QBO Authorization Failed</h2>
<p>${e.message || e}</p>
<a href="/">Zurück zur App</a>
`);
}
});
// 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' });
}
});
// =====================================================
// QBO PAYMENT ENDPOINTS v3 — In server.js einfügen
// - Invoice payments (multi, partial, overpay)
// - Downpayment (separate endpoint, called from customer view)
// - Customer credit query
// =====================================================
// --- Bank-Konten aus QBO ---
app.get('/api/qbo/accounts', async (req, res) => {
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';
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- Payment Methods aus QBO ---
app.get('/api/qbo/payment-methods', async (req, res) => {
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';
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- Record Payment (against invoices) ---
app.post('/api/qbo/record-payment', async (req, res) => {
const {
invoice_payments, // [{ invoice_id, amount }]
payment_date,
reference_number,
payment_method_id,
payment_method_name,
deposit_to_account_id,
deposit_to_account_name
} = req.body;
if (!invoice_payments || invoice_payments.length === 0) {
return res.status(400).json({ error: 'No invoices selected.' });
}
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';
const ids = invoice_payments.map(ip => ip.invoice_id);
const result = await dbClient.query(
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = ANY($1)`, [ids]
);
const invoicesData = result.rows;
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
if (notInQbo.length > 0) {
return res.status(400).json({
error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
});
}
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
if (custIds.length > 1) {
return res.status(400).json({ error: 'All invoices must belong to the same customer.' });
}
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
const qboPayment = {
CustomerRef: { value: custIds[0] },
TotalAmt: totalAmt,
TxnDate: payment_date,
PaymentRefNum: reference_number || '',
PaymentMethodRef: { value: payment_method_id },
DepositToAccountRef: { value: deposit_to_account_id },
Line: invoicesData.map(inv => ({
Amount: paymentMap.get(inv.id) || parseFloat(inv.total),
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
}))
};
console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`);
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboPayment)
});
const data = response.getJson ? response.getJson() : response.json;
if (!data.Payment) {
return res.status(500).json({
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
});
}
const qboPaymentId = data.Payment.Id;
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
await dbClient.query('BEGIN');
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[payment_date, reference_number || null, payment_method_name || 'Check',
deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId]
);
const localPaymentId = payResult.rows[0].id;
for (const ip of invoice_payments) {
const payAmt = parseFloat(ip.amount);
const inv = invoicesData.find(i => i.id === ip.invoice_id);
const invTotal = inv ? parseFloat(inv.total) : 0;
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[localPaymentId, ip.invoice_id, payAmt]
);
if (payAmt >= invTotal) {
await dbClient.query(
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[payment_date, ip.invoice_id]
);
}
}
await dbClient.query('COMMIT');
res.json({
success: true,
payment_id: localPaymentId,
qbo_payment_id: qboPaymentId,
total: totalAmt,
invoices_paid: invoice_payments.length,
message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).`
});
} catch (error) {
await dbClient.query('ROLLBACK').catch(() => {});
console.error('❌ Payment Error:', error);
res.status(500).json({ error: 'Payment failed: ' + error.message });
} finally {
dbClient.release();
}
});
// =====================================================
// QBO INVOICE UPDATE — Sync local changes to QBO
// =====================================================
// Aktualisiert eine bereits exportierte Invoice in QBO.
// Benötigt qbo_id + qbo_sync_token (Optimistic Locking).
// Sendet alle Items neu (QBO ersetzt die Line-Items komplett).
app.post('/api/invoices/:id/update-qbo', async (req, res) => {
const { id } = req.params;
const QBO_LABOR_ID = '5';
const QBO_PARTS_ID = '9';
const dbClient = await pool.connect();
try {
// 1. Lokale Rechnung + Items laden
const invoiceRes = await dbClient.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.qbo_id) {
return res.status(400).json({ error: 'Invoice has not been exported to QBO yet. Use QBO Export first.' });
}
if (!invoice.qbo_sync_token && invoice.qbo_sync_token !== '0') {
return res.status(400).json({ error: 'Missing QBO SyncToken. Try resetting and re-exporting.' });
}
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]);
const items = itemsRes.rows;
// 2. QBO vorbereiten
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';
// 3. Aktuelle Invoice aus QBO laden um den neuesten SyncToken zu holen
console.log(`🔍 Lade aktuelle QBO Invoice ${invoice.qbo_id}...`);
const currentQboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const currentQboData = currentQboRes.getJson ? currentQboRes.getJson() : currentQboRes.json;
const currentQboInvoice = currentQboData.Invoice;
if (!currentQboInvoice) {
return res.status(500).json({ error: 'Could not load current invoice from QBO.' });
}
const currentSyncToken = currentQboInvoice.SyncToken;
console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`);
// 4. Line Items bauen
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;
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
}
};
});
// 5. QBO Update Payload — sparse update
// Id + SyncToken sind Pflicht. Alles was mitgesendet wird, wird aktualisiert.
const updatePayload = {
"Id": invoice.qbo_id,
"SyncToken": currentSyncToken,
"sparse": true,
"Line": lineItems,
"CustomerRef": { "value": invoice.customer_qbo_id },
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
};
console.log(`📤 Update QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.qbo_doc_number})...`);
const updateResponse = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload)
});
const updateData = updateResponse.getJson ? updateResponse.getJson() : updateResponse.json;
const updatedInvoice = updateData.Invoice || updateData;
if (!updatedInvoice.Id) {
console.error("QBO Update Response:", JSON.stringify(updateData, null, 2));
throw new Error("QBO did not return an updated invoice.");
}
console.log(`✅ QBO Invoice updated! New SyncToken: ${updatedInvoice.SyncToken}`);
// 6. Neuen SyncToken lokal speichern
await dbClient.query(
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[updatedInvoice.SyncToken, id]
);
res.json({
success: true,
qbo_id: updatedInvoice.Id,
sync_token: updatedInvoice.SyncToken,
message: `Invoice #${invoice.qbo_doc_number || invoice.invoice_number} updated in QBO.`
});
} catch (error) {
console.error("QBO Update 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 Update failed: " + errorDetails });
} finally {
dbClient.release();
}
});
// --- List local payments ---
app.get('/api/payments', async (req, res) => {
try {
const result = await pool.query(`
SELECT p.*, c.name as customer_name,
COALESCE(json_agg(json_build_object(
'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
FROM payments p
LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
LEFT JOIN invoices i ON i.id = pi.invoice_id
GROUP BY p.id, c.name ORDER BY p.payment_date DESC
`);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: 'Error fetching payments' });
}
});
// =====================================================
// Neue Server Endpoints — In server.js einfügen
// 1. Customer QBO Export
// 2. Labor Rate aus QBO
// =====================================================
// --- 1. Kunde nach QBO exportieren ---
app.post('/api/customers/:id/export-qbo', async (req, res) => {
const { id } = req.params;
try {
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
const customer = custResult.rows[0];
if (customer.qbo_id) return res.status(400).json({ error: 'Customer already in QBO' });
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';
const qboCustomer = {
DisplayName: customer.name,
CompanyName: customer.name,
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
Taxable: customer.taxable !== false,
Notes: customer.remarks || undefined
};
// Contact
if (customer.contact) {
const parts = customer.contact.trim().split(/\s+/);
if (parts.length >= 2) {
qboCustomer.GivenName = parts[0];
qboCustomer.FamilyName = parts.slice(1).join(' ');
} else {
qboCustomer.GivenName = parts[0];
}
}
// Address
const addr = {};
if (customer.line1) addr.Line1 = customer.line1;
if (customer.line2) addr.Line2 = customer.line2;
if (customer.line3) addr.Line3 = customer.line3;
if (customer.line4) addr.Line4 = customer.line4;
if (customer.city) addr.City = customer.city;
if (customer.state) addr.CountrySubDivisionCode = customer.state;
if (customer.zip_code) addr.PostalCode = customer.zip_code;
if (Object.keys(addr).length > 0) qboCustomer.BillAddr = addr;
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboCustomer)
});
const data = response.getJson ? response.getJson() : response.json;
const qboId = data.Customer?.Id;
if (!qboId) throw new Error('QBO returned no ID');
await pool.query('UPDATE customers SET qbo_id = $1 WHERE id = $2', [qboId, id]);
console.log(`✅ Customer "${customer.name}" exported to QBO (ID: ${qboId})`);
res.json({ success: true, qbo_id: qboId, name: customer.name });
} catch (error) {
console.error('QBO Customer Export Error:', error);
res.status(500).json({ error: 'Export failed: ' + error.message });
}
});
// --- 2. Labor Rate aus QBO laden ---
// Lädt den UnitPrice des "Labor" Items (ID 5) aus QBO
app.get('/api/qbo/labor-rate', async (req, res) => {
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';
// Item ID 5 = Labor
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/item/5`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const rate = data.Item?.UnitPrice || null;
console.log(`💰 QBO Labor Rate: $${rate}`);
res.json({ rate });
} catch (error) {
console.error('Error fetching labor rate:', error);
// Nicht kritisch — Fallback auf Frontend-Default
res.json({ rate: null });
}
});
// --- 3. Sync Payments from QBO ---
// Prüft alle offenen lokalen Invoices gegen QBO.
// Aktualisiert paid_date und payment_status (Paid/Deposited).
app.post('/api/qbo/sync-payments', async (req, res) => {
const dbClient = await pool.connect();
try {
// Alle lokalen Invoices die in QBO sind und noch aktualisiert werden könnten
// Auch bereits bezahlte prüfen um payment_status zu korrigieren (Paid↔Deposited)
const openResult = await dbClient.query(`
SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid
FROM invoices i
WHERE i.qbo_id IS NOT NULL
`);
const openInvoices = openResult.rows;
if (openInvoices.length === 0) {
await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]);
return res.json({ synced: 0, message: 'All invoices up to date.' });
}
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';
// QBO Invoices in Batches laden (max 50 IDs pro Query)
const batchSize = 50;
const qboInvoices = new Map();
for (let i = 0; i < openInvoices.length; i += batchSize) {
const batch = openInvoices.slice(i, i + batchSize);
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const invoices = data.QueryResponse?.Invoice || [];
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
}
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
let updated = 0;
let newPayments = 0;
await dbClient.query('BEGIN');
for (const localInv of openInvoices) {
const qboInv = qboInvoices.get(localInv.qbo_id);
if (!qboInv) continue;
const qboBalance = parseFloat(qboInv.Balance) || 0;
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
const localPaid = parseFloat(localInv.local_paid) || 0;
// Prüfe ob in QBO bezahlt/teilweise bezahlt
if (qboBalance === 0 && qboTotal > 0) {
// Voll bezahlt in QBO
const UNDEPOSITED_FUNDS_ID = '221';
let status = 'Paid';
if (qboInv.LinkedTxn) {
for (const txn of qboInv.LinkedTxn) {
if (txn.TxnType === 'Payment') {
try {
const pmRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
method: 'GET'
});
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
const payment = pmData.Payment;
if (payment && payment.DepositToAccountRef &&
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
status = 'Deposited';
}
} catch (e) { /* ignore */ }
}
}
}
// Update wenn sich etwas geändert hat
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
if (needsUpdate) {
await dbClient.query(
`UPDATE invoices SET
paid_date = COALESCE(paid_date, CURRENT_DATE),
payment_status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[status, localInv.id]
);
updated++;
console.log(` ✅ #${localInv.invoice_number}: ${status}`);
}
// Fehlenden Payment-Eintrag NUR erstellen wenn Differenz > $0.01
const diff = qboTotal - localPaid;
if (diff > 0.01) {
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
RETURNING id`,
[diff, localInv.id]
);
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[payResult.rows[0].id, localInv.id, diff]
);
newPayments++;
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
}
} else if (qboBalance > 0 && qboBalance < qboTotal) {
// Teilweise bezahlt in QBO
const qboPaid = qboTotal - qboBalance;
const diff = qboPaid - localPaid;
const needsUpdate = localInv.payment_status !== 'Partial';
if (needsUpdate) {
await dbClient.query(
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['Partial', localInv.id]
);
updated++;
}
// Payment nur erstellen wenn echte Differenz > $0.01
if (diff > 0.01) {
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
RETURNING id`,
[diff, localInv.id]
);
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[payResult.rows[0].id, localInv.id, diff]
);
newPayments++;
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
}
}
}
// Last sync timestamp speichern
await dbClient.query(`
INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1)
ON CONFLICT (key) DO UPDATE SET value = $1
`, [new Date().toISOString()]);
await dbClient.query('COMMIT');
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
res.json({
synced: updated,
new_payments: newPayments,
total_checked: openInvoices.length,
message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.`
});
} catch (error) {
await dbClient.query('ROLLBACK').catch(() => {});
console.error('❌ Sync Error:', error);
res.status(500).json({ error: 'Sync failed: ' + error.message });
} finally {
dbClient.release();
}
});
// --- 4. Last sync timestamp ---
app.get('/api/qbo/last-sync', async (req, res) => {
try {
const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'");
res.json({ last_sync: result.rows[0]?.value || null });
} catch (error) {
res.json({ last_sync: null });
}
});
// 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);
});