Files
invoice-system/src/services/qbo-service.js

336 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
};