|
|
|
|
@@ -1,17 +1,14 @@
|
|
|
|
|
// src/services/qbo-service.js
|
|
|
|
|
/**
|
|
|
|
|
* QuickBooks Online Service
|
|
|
|
|
* Handles QBO API interactions
|
|
|
|
|
*/
|
|
|
|
|
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo');
|
|
|
|
|
const { makeQboApiCall } = require('../../qbo_helper');
|
|
|
|
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
|
|
|
|
|
|
|
|
|
|
// QBO Item IDs
|
|
|
|
|
const QBO_LABOR_ID = '5';
|
|
|
|
|
const QBO_PARTS_ID = '9';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get OAuth client and company ID
|
|
|
|
|
*/
|
|
|
|
|
function getClientInfo() {
|
|
|
|
|
const oauthClient = getOAuthClient();
|
|
|
|
|
const companyId = oauthClient.getToken().realmId;
|
|
|
|
|
@@ -22,202 +19,198 @@ function getClientInfo() {
|
|
|
|
|
/**
|
|
|
|
|
* Export invoice to QBO
|
|
|
|
|
*/
|
|
|
|
|
async function exportInvoiceToQbo(invoiceId, pool) {
|
|
|
|
|
const client = await pool.connect();
|
|
|
|
|
try {
|
|
|
|
|
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]);
|
|
|
|
|
async function exportInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
|
|
|
|
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
|
|
|
|
|
`, [invoiceId]);
|
|
|
|
|
|
|
|
|
|
const invoice = invoiceRes.rows[0];
|
|
|
|
|
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
|
|
|
|
|
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 itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
|
|
|
|
const items = itemsRes.rows;
|
|
|
|
|
|
|
|
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
|
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
|
|
|
|
|
|
|
|
// Get next DocNumber
|
|
|
|
|
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();
|
|
|
|
|
// Get next DocNumber
|
|
|
|
|
const maxNumResult = await dbClient.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();
|
|
|
|
|
|
|
|
|
|
// Build line items
|
|
|
|
|
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 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 || "" }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 = {
|
|
|
|
|
"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 || "" }
|
|
|
|
|
};
|
|
|
|
|
const data = response.getJson ? response.getJson() : response.json;
|
|
|
|
|
|
|
|
|
|
// Retry on 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 (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;
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
} finally {
|
|
|
|
|
client.release();
|
|
|
|
|
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 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)
|
|
|
|
|
*/
|
|
|
|
|
async function syncInvoiceToQbo(invoiceId, pool) {
|
|
|
|
|
const client = await pool.connect();
|
|
|
|
|
try {
|
|
|
|
|
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]);
|
|
|
|
|
async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
|
|
|
|
const invoiceRes = await dbClient.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 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 { companyId, baseUrl } = getClientInfo();
|
|
|
|
|
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
|
|
|
|
|
|
|
|
|
// Get current sync token
|
|
|
|
|
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 { companyId, baseUrl } = getClientInfo();
|
|
|
|
|
|
|
|
|
|
const lineItems = itemsRes.rows.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";
|
|
|
|
|
const qboRes = await makeQboApiCall({
|
|
|
|
|
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
|
|
|
|
method: 'GET'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"DetailType": "SalesItemLineDetail",
|
|
|
|
|
"Amount": amount,
|
|
|
|
|
"Description": item.description,
|
|
|
|
|
"SalesItemLineDetail": {
|
|
|
|
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
|
|
|
|
"UnitPrice": rate,
|
|
|
|
|
"Qty": parseFloat(item.quantity) || 1
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
|
|
|
|
const currentSyncToken = qboData.Invoice?.SyncToken;
|
|
|
|
|
|
|
|
|
|
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 (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
|
|
|
|
|
|
|
|
|
|
const lineItems = itemsRes.rows.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";
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
"DetailType": "SalesItemLineDetail",
|
|
|
|
|
"Amount": amount,
|
|
|
|
|
"Description": item.description,
|
|
|
|
|
"SalesItemLineDetail": {
|
|
|
|
|
"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) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
const updateRes = await makeQboApiCall({
|
|
|
|
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(updatePayload)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await client.query(
|
|
|
|
|
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
|
|
|
|
[updated.SyncToken, invoiceId]
|
|
|
|
|
);
|
|
|
|
|
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
|
|
|
|
|
|
|
|
|
|
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
|
|
|
|
|
return { success: true, sync_token: updated.SyncToken };
|
|
|
|
|
} finally {
|
|
|
|
|
client.release();
|
|
|
|
|
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) {
|
|
|
|
|
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 = {
|
|
|
|
|
|