Compare commits

...

4 Commits

Author SHA1 Message Date
Andreas Knuth 27ecafea5f Merge branch 'main' into refactoring 2026-03-04 16:05:02 -06:00
Andreas Knuth 15d33a116c moved routes 2026-03-04 16:00:40 -06:00
Andreas Knuth 0fbb298e89 bug fixing 2026-03-04 15:19:40 -06:00
Andreas Knuth 6d0f4c49be mark sent for partial 2026-03-02 10:53:45 -06:00
7 changed files with 227 additions and 220 deletions

View File

@ -252,7 +252,7 @@ function renderInvoiceRow(invoice) {
// Mark Sent button (right side) — only when open, not paid/partial // Mark Sent button (right side) — only when open, not paid/partial
let sendBtn = ''; let sendBtn = '';
if (hasQbo && !paid && !partial && !overdue && invoice.email_status !== 'sent') { if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`; sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
} }

View File

@ -1,5 +1,11 @@
// src/config/qbo.js
const OAuthClient = require('intuit-oauth'); const OAuthClient = require('intuit-oauth');
const { getOAuthClient: getClient, saveTokens, resetOAuthClient } = require('../../qbo_helper'); const {
getOAuthClient: getClient,
saveTokens,
resetOAuthClient,
makeQboApiCall // <-- NEU: Direkt hier mit importieren
} = require('../../qbo_helper');
function getOAuthClient() { function getOAuthClient() {
return getClient(); return getClient();
@ -16,5 +22,6 @@ module.exports = {
getOAuthClient, getOAuthClient,
getQboBaseUrl, getQboBaseUrl,
saveTokens, saveTokens,
resetOAuthClient resetOAuthClient,
makeQboApiCall // <-- NEU: Und sauber weiterreichen
}; };

View File

@ -3,8 +3,13 @@
* Modularized Backend * Modularized Backend
*/ */
const express = require('express'); const express = require('express');
const path = require('path');
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
// Import config
const { pool } = require('./config/database');
const { OAuthClient, getOAuthClient, saveTokens } = require('./config/qbo');
// Import routes // Import routes
const customerRoutes = require('./routes/customers'); const customerRoutes = require('./routes/customers');
const quoteRoutes = require('./routes/quotes'); const quoteRoutes = require('./routes/quotes');
@ -49,6 +54,7 @@ async function initBrowser() {
browser.on('disconnected', () => { browser.on('disconnected', () => {
console.log('[BROWSER] Browser disconnected, restarting...'); console.log('[BROWSER] Browser disconnected, restarting...');
browser = null; browser = null;
setBrowser(null);
initBrowser(); initBrowser();
}); });
} }
@ -57,9 +63,42 @@ async function initBrowser() {
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
app.use(express.static('public')); app.use(express.static(path.join(__dirname, '..', 'public')));
// Mount routes // =====================================================
// QBO OAuth Routes — mounted at root level (not under /api/qbo)
// These must match the Intuit callback URL configuration
// =====================================================
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);
});
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();
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>
`);
}
});
// =====================================================
// API Routes
// =====================================================
app.use('/api/customers', customerRoutes); app.use('/api/customers', customerRoutes);
app.use('/api/quotes', quoteRoutes); app.use('/api/quotes', quoteRoutes);
app.use('/api/invoices', invoiceRoutes); app.use('/api/invoices', invoiceRoutes);
@ -81,7 +120,6 @@ process.on('SIGTERM', async () => {
if (browser) { if (browser) {
await browser.close(); await browser.close();
} }
const { pool } = require('./config/database');
await pool.end(); await pool.end();
process.exit(0); process.exit(0);
}); });

View File

@ -5,8 +5,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { pool } = require('../config/database'); const { pool } = require('../config/database');
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo'); const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
const { makeQboApiCall } = require('../../qbo_helper');
// GET all customers // GET all customers
router.get('/', async (req, res) => { router.get('/', async (req, res) => {

View File

@ -11,8 +11,7 @@ const { getNextInvoiceNumber } = require('../utils/numberGenerators');
const { formatDate, formatMoney } = require('../utils/helpers'); const { formatDate, formatMoney } = require('../utils/helpers');
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service'); const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service');
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service'); const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo'); const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
const { makeQboApiCall } = require('../../qbo_helper');
// GET all invoices // GET all invoices
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
@ -133,7 +132,7 @@ router.post('/', async (req, res) => {
// Auto QBO Export // Auto QBO Export
let qboResult = null; let qboResult = null;
try { try {
qboResult = await exportInvoiceToQbo(invoiceId, pool); qboResult = await exportInvoiceToQbo(invoiceId, client);
if (qboResult.skipped) { if (qboResult.skipped) {
console.log(` Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`); console.log(` Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
} }
@ -224,9 +223,9 @@ router.put('/:id', async (req, res) => {
const hasQboId = !!checkRes.rows[0]?.qbo_id; const hasQboId = !!checkRes.rows[0]?.qbo_id;
if (hasQboId) { if (hasQboId) {
qboResult = await syncInvoiceToQbo(id, pool); qboResult = await syncInvoiceToQbo(id, client);
} else { } else {
qboResult = await exportInvoiceToQbo(id, pool); qboResult = await exportInvoiceToQbo(id, client);
} }
if (qboResult.skipped) { if (qboResult.skipped) {

View File

@ -1,12 +1,12 @@
/** /**
* QBO Routes * QBO Routes
* Handles QBO OAuth, sync, and data operations * Handles QBO sync and data operations
* NOTE: OAuth auth/callback routes are in index.js (root-level paths)
*/ */
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { pool } = require('../config/database'); const { pool } = require('../config/database');
const { getOAuthClient, getQboBaseUrl, saveTokens } = require('../config/qbo'); const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
const { makeQboApiCall } = require('../../qbo_helper');
// GET QBO status // GET QBO status
router.get('/status', (req, res) => { router.get('/status', (req, res) => {
@ -23,35 +23,6 @@ router.get('/status', (req, res) => {
} }
}); });
// GET auth URL - redirects to Intuit
router.get('/auth', (req, res) => {
const client = getOAuthClient();
const authUri = client.authorizeUri({
scope: [require('../config/qbo').OAuthClient.scopes.Accounting],
state: 'intuit-qbo-auth'
});
console.log('🔗 Redirecting to QBO Authorization:', authUri);
res.redirect(authUri);
});
// OAuth callback
router.get('/auth/callback', async (req, res) => {
const client = getOAuthClient();
try {
const authResponse = await client.createToken(req.url);
console.log('✅ QBO Authorization erfolgreich!');
saveTokens();
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>
`);
}
});
// GET bank accounts from QBO // GET bank accounts from QBO
router.get('/accounts', async (req, res) => { router.get('/accounts', async (req, res) => {
try { try {

View File

@ -1,17 +1,14 @@
// src/services/qbo-service.js
/** /**
* QuickBooks Online Service * QuickBooks Online Service
* Handles QBO API interactions * Handles QBO API interactions
*/ */
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo'); const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
const { makeQboApiCall } = require('../../qbo_helper');
// QBO Item IDs // QBO Item IDs
const QBO_LABOR_ID = '5'; const QBO_LABOR_ID = '5';
const QBO_PARTS_ID = '9'; const QBO_PARTS_ID = '9';
/**
* Get OAuth client and company ID
*/
function getClientInfo() { function getClientInfo() {
const oauthClient = getOAuthClient(); const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId; const companyId = oauthClient.getToken().realmId;
@ -22,202 +19,198 @@ function getClientInfo() {
/** /**
* Export invoice to QBO * Export invoice to QBO
*/ */
async function exportInvoiceToQbo(invoiceId, pool) { async function exportInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
const client = await pool.connect(); const invoiceRes = await dbClient.query(`
try { SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
const invoiceRes = await client.query(` FROM invoices i
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email LEFT JOIN customers c ON i.customer_id = c.id
FROM invoices i WHERE i.id = $1
LEFT JOIN customers c ON i.customer_id = c.id `, [invoiceId]);
WHERE i.id = $1
`, [invoiceId]);
const invoice = invoiceRes.rows[0]; const invoice = invoiceRes.rows[0];
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' }; 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 itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
const items = itemsRes.rows; const items = itemsRes.rows;
const { companyId, baseUrl } = getClientInfo(); const { companyId, baseUrl } = getClientInfo();
// Get next DocNumber // Get next DocNumber
const maxNumResult = await client.query(` const maxNumResult = await dbClient.query(`
SELECT GREATEST( SELECT GREATEST(
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0), 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) COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
) as max_num ) as max_num
`); `);
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString(); let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
// Build line items const lineItems = items.map(item => {
const lineItems = items.map(item => { const parseNum = (val) => {
const parseNum = (val) => { if (val === null || val === undefined) return 0;
if (val === null || val === undefined) return 0; if (typeof val === 'number') return val;
if (typeof val === 'number') return val; return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0; };
}; const rate = parseNum(item.rate);
const rate = parseNum(item.rate); const qty = parseNum(item.quantity) || 1;
const qty = parseNum(item.quantity) || 1; const amount = rate * qty;
const amount = rate * qty; const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
const itemRefId = item.qbo_item_id || QBO_PARTS_ID; const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
return { return {
"DetailType": "SalesItemLineDetail", "DetailType": "SalesItemLineDetail",
"Amount": amount, "Amount": amount,
"Description": item.description, "Description": item.description,
"SalesItemLineDetail": { "SalesItemLineDetail": {
"ItemRef": { "value": itemRefId, "name": itemRefName }, "ItemRef": { "value": itemRefId, "name": itemRefName },
"UnitPrice": rate, "UnitPrice": rate,
"Qty": qty "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 || "" }
};
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 qboPayload = { const data = response.getJson ? response.getJson() : response.json;
"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 on duplicate if (data.Fault?.Error?.[0]?.code === '6140') {
let qboInvoice = null; console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`);
for (let attempt = 0; attempt < 5; attempt++) { qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`); continue;
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 (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;
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.'); throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500));
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 };
} finally {
client.release();
} }
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
await dbClient.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 };
} }
/** /**
* Sync invoice to QBO (update) * Sync invoice to QBO (update)
*/ */
async function syncInvoiceToQbo(invoiceId, pool) { async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
const client = await pool.connect(); const invoiceRes = await dbClient.query(`
try { SELECT i.*, c.qbo_id as customer_qbo_id
const invoiceRes = await client.query(` FROM invoices i
SELECT i.*, c.qbo_id as customer_qbo_id LEFT JOIN customers c ON i.customer_id = c.id
FROM invoices i WHERE i.id = $1
LEFT JOIN customers c ON i.customer_id = c.id `, [invoiceId]);
WHERE i.id = $1
`, [invoiceId]);
const invoice = invoiceRes.rows[0]; const invoice = invoiceRes.rows[0];
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' }; 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 itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
const { companyId, baseUrl } = getClientInfo();
// Get current sync token const { companyId, baseUrl } = getClientInfo();
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 qboRes = await makeQboApiCall({
const parseNum = (val) => { url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
if (val === null || val === undefined) return 0; method: 'GET'
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 { const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
"DetailType": "SalesItemLineDetail", const currentSyncToken = qboData.Invoice?.SyncToken;
"Amount": amount,
"Description": item.description,
"SalesItemLineDetail": {
"ItemRef": { "value": itemRefId, "name": itemRefName },
"UnitPrice": rate,
"Qty": parseFloat(item.quantity) || 1
}
};
});
const updatePayload = { if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
"Id": invoice.qbo_id,
"SyncToken": currentSyncToken, const lineItems = itemsRes.rows.map(item => {
"sparse": true, const parseNum = (val) => {
"Line": lineItems, if (val === null || val === undefined) return 0;
"CustomerRef": { "value": invoice.customer_qbo_id }, if (typeof val === 'number') return val;
"TxnDate": invoice.invoice_date.toISOString().split('T')[0], return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
}; };
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";
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`); return {
const updateRes = await makeQboApiCall({ "DetailType": "SalesItemLineDetail",
url: `${baseUrl}/v3/company/${companyId}/invoice`, "Amount": amount,
method: 'POST', "Description": item.description,
headers: { 'Content-Type': 'application/json' }, "SalesItemLineDetail": {
body: JSON.stringify(updatePayload) "ItemRef": { "value": itemRefId, "name": itemRefName },
}); "UnitPrice": rate,
"Qty": qty
}
};
});
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json; 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}` : "" }
};
if (updateData.Fault) { console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
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; const updateRes = await makeQboApiCall({
if (!updated.Id) { url: `${baseUrl}/v3/company/${companyId}/invoice`,
console.error(`❌ QBO unexpected response:`, JSON.stringify(updateData).substring(0, 500)); method: 'POST',
throw new Error('QBO update returned no ID'); headers: { 'Content-Type': 'application/json' },
} body: JSON.stringify(updatePayload)
});
await client.query( const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
'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})`); if (updateData.Fault) {
return { success: true, sync_token: updated.SyncToken }; const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault);
} finally { console.error(`❌ QBO Sync Fault:`, errMsg);
client.release(); throw new Error('QBO sync failed: ' + errMsg);
} }
const updated = updateData.Invoice || updateData;
if (!updated.Id) {
throw new Error('QBO update returned no ID');
}
await dbClient.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 };
} }
module.exports = { module.exports = {