diff --git a/src/config/qbo.js b/src/config/qbo.js index 90a38d3..0ce2a7d 100644 --- a/src/config/qbo.js +++ b/src/config/qbo.js @@ -1,5 +1,11 @@ +// src/config/qbo.js 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() { return getClient(); @@ -16,5 +22,6 @@ module.exports = { getOAuthClient, getQboBaseUrl, saveTokens, - resetOAuthClient -}; + resetOAuthClient, + makeQboApiCall // <-- NEU: Und sauber weiterreichen +}; \ No newline at end of file diff --git a/src/routes/customers.js b/src/routes/customers.js index 655f544..24e8b6b 100644 --- a/src/routes/customers.js +++ b/src/routes/customers.js @@ -5,8 +5,7 @@ const express = require('express'); const router = express.Router(); const { pool } = require('../config/database'); -const { getOAuthClient, getQboBaseUrl } = require('../config/qbo'); -const { makeQboApiCall } = require('../../qbo_helper'); +const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // GET all customers router.get('/', async (req, res) => { diff --git a/src/routes/invoices.js b/src/routes/invoices.js index 91d83d8..5655282 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -11,8 +11,7 @@ const { getNextInvoiceNumber } = require('../utils/numberGenerators'); const { formatDate, formatMoney } = require('../utils/helpers'); const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service'); const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service'); -const { getOAuthClient, getQboBaseUrl } = require('../config/qbo'); -const { makeQboApiCall } = require('../../qbo_helper'); +const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // GET all invoices router.get('/', async (req, res) => { @@ -133,7 +132,7 @@ router.post('/', async (req, res) => { // Auto QBO Export let qboResult = null; try { - qboResult = await exportInvoiceToQbo(invoiceId, pool); + qboResult = await exportInvoiceToQbo(invoiceId, client); if (qboResult.skipped) { 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; if (hasQboId) { - qboResult = await syncInvoiceToQbo(id, pool); + qboResult = await syncInvoiceToQbo(id, client); } else { - qboResult = await exportInvoiceToQbo(id, pool); + qboResult = await exportInvoiceToQbo(id, client); } if (qboResult.skipped) { diff --git a/src/routes/qbo.js b/src/routes/qbo.js index 808804a..b72a537 100644 --- a/src/routes/qbo.js +++ b/src/routes/qbo.js @@ -5,8 +5,7 @@ const express = require('express'); const router = express.Router(); const { pool } = require('../config/database'); -const { getOAuthClient, getQboBaseUrl, saveTokens } = require('../config/qbo'); -const { makeQboApiCall } = require('../../qbo_helper'); +const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // GET QBO status router.get('/status', (req, res) => { diff --git a/src/services/qbo-service.js b/src/services/qbo-service.js index 1921a09..da98d31 100644 --- a/src/services/qbo-service.js +++ b/src/services/qbo-service.js @@ -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 = { @@ -226,4 +219,4 @@ module.exports = { getClientInfo, exportInvoiceToQbo, syncInvoiceToQbo -}; +}; \ No newline at end of file