diff --git a/public/app.js b/public/app.js
index 7014739..6790cf7 100644
--- a/public/app.js
+++ b/public/app.js
@@ -940,61 +940,65 @@ function getInvoiceItems() {
async function handleInvoiceSubmit(e) {
e.preventDefault();
-
- const items = getInvoiceItems();
-
- if (items.length === 0) {
- alert('Please add at least one item');
- return;
- }
-
- const invoiceNumber = document.getElementById('invoice-number').value.trim();
-
- // Invoice Number ist jetzt OPTIONAL
- // Wenn angegeben, muss sie numerisch sein
- if (invoiceNumber && !/^\d+$/.test(invoiceNumber)) {
- alert('Invalid invoice number. Must be a numeric value or left empty.');
- return;
- }
-
+
const data = {
- invoice_number: invoiceNumber || null, // null wenn leer
- customer_id: parseInt(document.getElementById('invoice-customer').value),
+ invoice_number: document.getElementById('invoice-number').value || null,
+ customer_id: document.getElementById('invoice-customer').value,
invoice_date: document.getElementById('invoice-date').value,
terms: document.getElementById('invoice-terms').value,
auth_code: document.getElementById('invoice-authorization').value,
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
- scheduled_send_date: document.getElementById('invoice-send-date').value || null,
- items: items
+ scheduled_send_date: document.getElementById('invoice-send-date')?.value || null,
+ items: getInvoiceItems() // Deine bestehende Funktion
};
-
+
+ if (!data.customer_id) {
+ alert('Please select a customer.');
+ return;
+ }
+ if (!data.items || data.items.length === 0) {
+ alert('Please add at least one item.');
+ return;
+ }
+
+ const invoiceId = currentInvoiceId;
+ const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices';
+ const method = invoiceId ? 'PUT' : 'POST';
+
+ // Spinner anzeigen
+ showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
+
try {
- let response;
- if (currentInvoiceId) {
- response = await fetch(`/api/invoices/${currentInvoiceId}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- });
- } else {
- response = await fetch('/api/invoices', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- });
- }
-
+ const response = await fetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+
const result = await response.json();
-
+
if (response.ok) {
closeInvoiceModal();
- loadInvoices();
+
+ // Info über QBO Status
+ if (result.qbo_doc_number) {
+ console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`);
+ } else if (result.qbo_synced) {
+ console.log('✅ Invoice saved & synced to QBO');
+ } else {
+ console.log('✅ Invoice saved locally (QBO sync pending)');
+ }
+
+ // Invoices neu laden
+ if (window.invoiceView) window.invoiceView.loadInvoices();
} else {
- alert(result.error || 'Error saving invoice');
+ alert(`Error: ${result.error}`);
}
} catch (error) {
console.error('Error:', error);
alert('Error saving invoice');
+ } finally {
+ hideSpinner();
}
}
diff --git a/public/customer-view.js b/public/customer-view.js
index 843854f..d3f2d62 100644
--- a/public/customer-view.js
+++ b/public/customer-view.js
@@ -313,6 +313,8 @@ async function handleSubmit(e) {
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
const method = customerId ? 'PUT' : 'POST';
+ if (typeof showSpinner === 'function') showSpinner(customerId ? 'Saving customer & syncing QBO...' : 'Creating customer...');
+
try {
const response = await fetch(url, {
method,
@@ -330,6 +332,8 @@ async function handleSubmit(e) {
} catch (error) {
console.error('Error saving customer:', error);
alert('Network error saving customer.');
+ } finally {
+ if (typeof hideSpinner === 'function') hideSpinner();
}
}
diff --git a/public/invoice-view.js b/public/invoice-view.js
index b1356cc..5ac9782 100644
--- a/public/invoice-view.js
+++ b/public/invoice-view.js
@@ -221,11 +221,11 @@ function renderInvoiceRow(invoice) {
const customerHasQbo = !!invoice.customer_qbo_id;
let qboBtn;
if (hasQbo) {
- qboBtn = ``;
+ qboBtn = `✓ QBO`;
} else if (!customerHasQbo) {
qboBtn = `QBO ⚠`;
} else {
- qboBtn = ``;
+ qboBtn = `QBO pending`;
}
const pdfBtn = draft
diff --git a/server.js b/server.js
index 566469a..770a28b 100644
--- a/server.js
+++ b/server.js
@@ -10,6 +10,9 @@ 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;
@@ -121,6 +124,174 @@ async function getNextInvoiceNumber() {
return String(parseInt(result.rows[0].max_number) + 1);
}
+// --- 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 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 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": "EmailSent",
+ "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') {
+ qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
+ continue;
+ }
+ qboInvoice = data.Invoice || data;
+ if (qboInvoice.Id) break;
+ throw new Error("QBO returned no ID: " + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data)));
+ }
+
+ 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 => {
+ 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 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;
+ const updated = updateData.Invoice || updateData;
+ if (!updated.Id) 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 };
+}
+
+
// Logo endpoints
app.get('/api/logo-info', async (req, res) => {
try {
@@ -584,64 +755,74 @@ 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, created_from_quote_id, scheduled_send_date } = req.body;
-
+ 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 ist jetzt OPTIONAL — wird erst beim QBO Export vergeben
- // Wenn angegeben, muss sie numerisch sein und darf nicht existieren
- if (invoice_number && invoice_number.trim() !== '') {
- if (!/^\d+$/.test(invoice_number)) {
- await client.query('ROLLBACK');
- return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
- }
-
- const existingInvoice = await client.query(
- 'SELECT id FROM invoices WHERE invoice_number = $1',
- [invoice_number]
- );
-
- if (existingInvoice.rows.length > 0) {
+
+ // 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;
- }
+ if (!isNaN(amount)) subtotal += amount;
}
-
+
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
-
- // invoice_number kann NULL sein
- const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
- const sendDate = scheduled_send_date || null;
-
+
const invoiceResult = await client.query(
- `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, scheduled_send_date)
+ `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 *`,
- [invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, sendDate]
+ [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');
- res.json(invoiceResult.rows[0]);
+
+ // 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);
@@ -720,61 +901,76 @@ 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_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein
- const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
-
- if (invNum && !/^\d+$/.test(invNum)) {
+
+ // Invoice-Nummer validieren (falls angegeben)
+ if (invoice_number && !/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
- return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
+ return res.status(400).json({ error: 'Invoice number must be numeric.' });
}
-
- if (invNum) {
- const existingInvoice = await client.query(
- 'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
- [invNum, id]
- );
- if (existingInvoice.rows.length > 0) {
+
+ 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 ${invNum} already exists.` });
+ 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;
- }
+ if (!isNaN(amount)) subtotal += amount;
}
-
+
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
- const sendDate = scheduled_send_date || null;
-
- await client.query(
- `UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6,
- tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, updated_at = CURRENT_TIMESTAMP
- WHERE id = $12`,
- [invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, sendDate, id]
- );
-
+
+ // 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');
- res.json({ success: true });
+
+ // Auto QBO Sync (falls bereits in QBO)
+ let qboResult = null;
+ try {
+ qboResult = await syncInvoiceToQbo(id, client);
+ if (qboResult.skipped) {
+ console.log(`ℹ️ Invoice ${id} not synced to QBO: ${qboResult.reason}`);
+ }
+ } catch (qboErr) {
+ console.error(`⚠️ Auto QBO sync failed for Invoice ${id}:`, qboErr.message);
+ }
+
+ res.json({ success: true, qbo_synced: !!qboResult?.success });
+
} catch (error) {
await client.query('ROLLBACK');
console.error('Error updating invoice:', error);
@@ -784,7 +980,6 @@ app.put('/api/invoices/:id', async (req, res) => {
}
});
-
app.delete('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();