This commit is contained in:
Andreas Knuth 2026-02-24 18:40:57 -06:00
parent b5ac7f0807
commit ab2f064de9
1 changed files with 207 additions and 176 deletions

383
server.js
View File

@ -10,9 +10,6 @@ const { makeQboApiCall, getOAuthClient, saveTokens, resetOAuthClient } = require
const app = express();
const PORT = process.env.PORT || 3000;
const QBO_LABOR_ID = '5';
const QBO_PARTS_ID = '9';
// Global browser instance
let browser = null;
@ -124,6 +121,15 @@ async function getNextInvoiceNumber() {
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(`
@ -155,8 +161,14 @@ async function exportInvoiceToQbo(invoiceId, client) {
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
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 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 {
@ -166,7 +178,7 @@ async function exportInvoiceToQbo(invoiceId, client) {
"SalesItemLineDetail": {
"ItemRef": { "value": itemRefId, "name": itemRefName },
"UnitPrice": rate,
"Qty": parseFloat(item.quantity) || 1
"Qty": qty
}
};
});
@ -250,10 +262,20 @@ async function syncInvoiceToQbo(invoiceId, client) {
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
const lineItems = itemsRes.rows.map(item => {
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
// 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,
@ -309,6 +331,183 @@ async function syncInvoiceToQbo(invoiceId, client) {
}
// =====================================================
// 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 {
@ -771,85 +970,6 @@ app.get('/api/invoices/:id', async (req, res) => {
});
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();
}
});
app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
const { id } = req.params;
@ -916,96 +1036,7 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
}
});
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();
}
});
app.delete('/api/invoices/:id', async (req, res) => {
const { id } = req.params;