env param & single source of truth

This commit is contained in:
2026-05-06 16:41:02 -05:00
parent 9804698572
commit 6662b3cb18
3 changed files with 121 additions and 171 deletions

View File

@@ -217,12 +217,120 @@ async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClie
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
syncInvoiceToQbo,
recordStripePaymentInQbo
};

View File

@@ -9,10 +9,9 @@
* - Checks each via Stripe API
* - Records payment + QBO booking if paid
*/
const { recordStripePaymentInQbo } = require('./qbo-service');
const { pool } = require('../config/database');
const { checkPaymentStatus, deactivatePaymentLink, calculateStripeFee } = require('./stripe-service');
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
const POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
const STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 minutes after boot
@@ -119,8 +118,11 @@ async function pollStripePayments() {
// 4. QBO booking
if (invoice.qbo_id && invoice.customer_qbo_id) {
try {
await recordStripePaymentInQbo(invoice, amountReceived, methodLabel, stripeFee,
status.details.paymentIntentId || '');
await recordStripePaymentInQbo(
invoice, amountReceived, methodLabel, stripeFee,
status.details.paymentIntentId || '',
{ source: 'auto-polled' } // ← NEU: ein zusätzlicher Optionen-Parameter
);
} catch (qboErr) {
console.error(` ⚠️ QBO booking failed for #${invoice.invoice_number}:`, qboErr.message);
}
@@ -146,78 +148,6 @@ async function pollStripePayments() {
}
}
/**
* Record Stripe payment in QBO (same logic as in invoices.js check-payment route)
*/
async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, reference) {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
// 1. 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} — auto-polled`,
Line: [{
Amount: amount,
LinkedTxn: [{
TxnId: invoice.qbo_id,
TxnType: 'Invoice'
}]
}],
DepositToAccountRef: { value: '221' }
};
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: ID ${paymentData.Payment?.Id}`);
// 2. QBO Expense for fee
if (stripeFee > 0) {
const expensePayload = {
AccountRef: { value: '244', name: 'PlainsCapital Bank' },
TxnDate: new Date().toISOString().split('T')[0],
PaymentType: 'Check',
PrivateNote: `Stripe fee for Invoice #${invoice.invoice_number} (${methodLabel}) — auto-polled`,
Line: [{
DetailType: 'AccountBasedExpenseLineDetail',
Amount: stripeFee,
AccountBasedExpenseLineDetail: {
AccountRef: { value: '1150040001', name: 'Payment Processing Fees' }
},
Description: `Stripe ${methodLabel} fee — Invoice #${invoice.invoice_number}`
}]
};
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 Fee booking failed:`, JSON.stringify(expenseData.Fault));
} else {
console.log(` 📗 QBO Fee: ID ${expenseData.Purchase?.Id} ($${stripeFee.toFixed(2)})`);
}
}
}
/**
* Start the polling scheduler
*/