// src/services/qbo-service.js /** * QuickBooks Online Service * Handles QBO API interactions */ const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import // QBO Item IDs const QBO_LABOR_ID = '5'; const QBO_PARTS_ID = '9'; const QBO_SUBSCRIPTION_ID = '115'; function getClientInfo() { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); return { oauthClient, companyId, baseUrl }; } /** * Export invoice to QBO */ 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 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(); // 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(); 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" : itemRefId == QBO_SUBSCRIPTION_ID ? "Subscription" : "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 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 (!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, 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 itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]); 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 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" : itemRefId == QBO_SUBSCRIPTION_ID ? "Subscription" : "Parts:Parts"; return { "DetailType": "SalesItemLineDetail", "Amount": amount, "Description": item.description, "SalesItemLineDetail": { "ItemRef": { "value": itemRefId, "name": itemRefName }, "UnitPrice": rate, "Qty": qty } }; }); 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; 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 }; } /** * Records a Stripe payment in QBO: * 1) Creates a QBO Payment linked to the invoice (deposited to Undeposited Funds) * 2) Optionally creates a Purchase/Expense for the Stripe fee * Controlled by env QBO_BOOK_STRIPE_FEES — default OFF * * @param {Object} invoice - Invoice row (must have qbo_id, customer_qbo_id, invoice_number) * @param {number} amount - Amount received (gross from Stripe) * @param {string} methodLabel - 'ACH' or 'Credit Card' * @param {number} stripeFee - Stripe processing fee (informational only unless flag set) * @param {string} reference - Stripe paymentIntentId / sessionId * @param {Object} [opts] * @param {string} [opts.source] - 'manual' | 'auto-polled' (only affects PrivateNote text) * @returns {{ paymentId: string, feeBooked: boolean, feeSkipped: boolean }} */ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, reference, opts = {}) { const { companyId, baseUrl } = getClientInfo(); const source = opts.source || 'manual'; const sourceSuffix = source === 'auto-polled' ? ' — auto-polled' : ''; // ── 1. Create QBO Payment ── const paymentPayload = { CustomerRef: { value: invoice.customer_qbo_id }, TotalAmt: amount, TxnDate: new Date().toISOString().split('T')[0], PaymentRefNum: reference ? reference.substring(0, 21) : 'Stripe', PrivateNote: `Stripe ${methodLabel}${sourceSuffix}`, Line: [{ Amount: amount, LinkedTxn: [{ TxnId: invoice.qbo_id, TxnType: 'Invoice' }] }], // Deposit to Undeposited Funds — Stripe payout reconciles this later DepositToAccountRef: { value: '221' } }; console.log(`📤 QBO Payment: $${amount.toFixed(2)} for Invoice #${invoice.invoice_number} (${methodLabel}${sourceSuffix})...`); const paymentRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/payment`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(paymentPayload) }); const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json; if (paymentData.Fault) { const errMsg = paymentData.Fault.Error?.map(e => `${e.Message}: ${e.Detail}`).join('; '); throw new Error('QBO Payment failed: ' + errMsg); } console.log(`✅ QBO Payment created: ID ${paymentData.Payment?.Id}`); // ── 2. Create QBO Expense for Stripe Fee ── // Only if explicitly enabled via env flag. We get the fee details from Stripe payout reports // for accounting, so booking them as separate expenses in QBO is redundant. const bookFee = process.env.QBO_BOOK_STRIPE_FEES === 'true'; if (stripeFee > 0 && !bookFee) { console.log(`â„šī¸ Stripe fee $${stripeFee.toFixed(2)} NOT booked in QBO (QBO_BOOK_STRIPE_FEES != 'true')`); return { paymentId: paymentData.Payment?.Id, feeBooked: false, feeSkipped: true }; } if (stripeFee > 0 && bookFee) { const expensePayload = { AccountRef: { value: '244', name: 'PlainsCapital Bank' }, TxnDate: new Date().toISOString().split('T')[0], PaymentType: 'Check', PrivateNote: `Stripe processing fee for Invoice #${invoice.invoice_number} (${methodLabel})${sourceSuffix}`, Line: [{ DetailType: 'AccountBasedExpenseLineDetail', Amount: stripeFee, AccountBasedExpenseLineDetail: { AccountRef: { value: '1150040001', name: 'Payment Processing Fees' } }, Description: `Stripe ${methodLabel} fee — Invoice #${invoice.invoice_number}` }] }; console.log(`📤 QBO Fee Expense: $${stripeFee.toFixed(2)}...`); const expenseRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/purchase`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(expensePayload) }); const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json; if (expenseData.Fault) { console.error('âš ī¸ QBO Expense booking failed:', JSON.stringify(expenseData.Fault)); // Don't throw — payment itself is valid } else { console.log(`✅ QBO Expense created: ID ${expenseData.Purchase?.Id}`); } } return { paymentId: paymentData.Payment?.Id, feeBooked: stripeFee > 0 && bookFee, feeSkipped: false }; } module.exports = { QBO_LABOR_ID, QBO_PARTS_ID, QBO_SUBSCRIPTION_ID, getClientInfo, exportInvoiceToQbo, syncInvoiceToQbo, recordStripePaymentInQbo };