336 lines
13 KiB
JavaScript
336 lines
13 KiB
JavaScript
// 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
|
||
}; |