env param & single source of truth
This commit is contained in:
@@ -14,6 +14,7 @@ const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-servic
|
|||||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
const { sendInvoiceEmail } = require('../services/email-service');
|
const { sendInvoiceEmail } = require('../services/email-service');
|
||||||
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
|
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
|
||||||
|
const { recordStripePaymentInQbo } = require('../services/qbo-service');
|
||||||
|
|
||||||
function calculateNextRecurringDate(invoiceDate, interval) {
|
function calculateNextRecurringDate(invoiceDate, interval) {
|
||||||
const d = new Date(invoiceDate);
|
const d = new Date(invoiceDate);
|
||||||
@@ -1163,18 +1164,11 @@ router.post('/:id/check-payment', async (req, res) => {
|
|||||||
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
||||||
|
|
||||||
// 4. QBO: Record Payment + Expense (if QBO-linked)
|
// 4. QBO: Record Payment + Expense (if QBO-linked)
|
||||||
let qboResult = null;
|
|
||||||
if (invoice.qbo_id && invoice.customer_qbo_id) {
|
|
||||||
try {
|
|
||||||
qboResult = await recordStripePaymentInQbo(
|
qboResult = await recordStripePaymentInQbo(
|
||||||
invoice, amountReceived, methodLabel, stripeFee,
|
invoice, amountReceived, methodLabel, stripeFee,
|
||||||
result.details.paymentIntentId || ''
|
result.details.paymentIntentId || ''
|
||||||
|
// kein source-Parameter — default ist 'manual'
|
||||||
);
|
);
|
||||||
} catch (qboErr) {
|
|
||||||
console.error(`⚠️ QBO booking failed for Invoice #${invoice.invoice_number}:`, qboErr.message);
|
|
||||||
qboResult = { error: qboErr.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbClient.query('COMMIT');
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
@@ -1198,89 +1192,7 @@ router.post('/:id/check-payment', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Record Stripe payment in QBO: Payment on Invoice + Expense for Stripe Fee.
|
|
||||||
*/
|
|
||||||
async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, reference) {
|
|
||||||
const oauthClient = getOAuthClient();
|
|
||||||
const companyId = oauthClient.getToken().realmId;
|
|
||||||
const baseUrl = getQboBaseUrl();
|
|
||||||
|
|
||||||
// --- 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} — processed via Payment Link`,
|
|
||||||
Line: [{
|
|
||||||
Amount: amount,
|
|
||||||
LinkedTxn: [{
|
|
||||||
TxnId: invoice.qbo_id,
|
|
||||||
TxnType: 'Invoice'
|
|
||||||
}]
|
|
||||||
}],
|
|
||||||
// Deposit to Undeposited Funds (Stripe will payout to bank later)
|
|
||||||
DepositToAccountRef: { value: '221' } // Undeposited Funds
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`📤 QBO: Recording Stripe payment $${amount.toFixed(2)} for Invoice #${invoice.invoice_number}...`);
|
|
||||||
|
|
||||||
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 ---
|
|
||||||
if (stripeFee > 0) {
|
|
||||||
const expensePayload = {
|
|
||||||
AccountRef: { value: '244', name: 'PlainsCapital Bank' }, // Checking
|
|
||||||
TxnDate: new Date().toISOString().split('T')[0],
|
|
||||||
PaymentType: 'Check',
|
|
||||||
PrivateNote: `Stripe processing fee for Invoice #${invoice.invoice_number} (${methodLabel})`,
|
|
||||||
Line: [{
|
|
||||||
DetailType: 'AccountBasedExpenseLineDetail',
|
|
||||||
Amount: stripeFee,
|
|
||||||
AccountBasedExpenseLineDetail: {
|
|
||||||
AccountRef: { value: '1150040001', name: 'Payment Processing Fees' }
|
|
||||||
},
|
|
||||||
Description: `Stripe ${methodLabel} fee — Invoice #${invoice.invoice_number}`
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`📤 QBO: Booking Stripe fee $${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 is still valid even if fee booking fails
|
|
||||||
} else {
|
|
||||||
console.log(`✅ QBO Expense created: ID ${expenseData.Purchase?.Id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
paymentId: paymentData.Payment?.Id,
|
|
||||||
feeBooked: stripeFee > 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// PATCH update sent dates only
|
// PATCH update sent dates only
|
||||||
router.patch('/:id/sent-dates', async (req, res) => {
|
router.patch('/:id/sent-dates', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
@@ -217,12 +217,120 @@ async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClie
|
|||||||
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
|
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
|
||||||
return { success: true, sync_token: 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 = {
|
module.exports = {
|
||||||
QBO_LABOR_ID,
|
QBO_LABOR_ID,
|
||||||
QBO_PARTS_ID,
|
QBO_PARTS_ID,
|
||||||
QBO_SUBSCRIPTION_ID,
|
QBO_SUBSCRIPTION_ID,
|
||||||
getClientInfo,
|
getClientInfo,
|
||||||
exportInvoiceToQbo,
|
exportInvoiceToQbo,
|
||||||
syncInvoiceToQbo
|
syncInvoiceToQbo,
|
||||||
|
recordStripePaymentInQbo
|
||||||
};
|
};
|
||||||
@@ -9,10 +9,9 @@
|
|||||||
* - Checks each via Stripe API
|
* - Checks each via Stripe API
|
||||||
* - Records payment + QBO booking if paid
|
* - Records payment + QBO booking if paid
|
||||||
*/
|
*/
|
||||||
|
const { recordStripePaymentInQbo } = require('./qbo-service');
|
||||||
const { pool } = require('../config/database');
|
const { pool } = require('../config/database');
|
||||||
const { checkPaymentStatus, deactivatePaymentLink, calculateStripeFee } = require('./stripe-service');
|
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 POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
||||||
const STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 minutes after boot
|
const STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 minutes after boot
|
||||||
@@ -119,8 +118,11 @@ async function pollStripePayments() {
|
|||||||
// 4. QBO booking
|
// 4. QBO booking
|
||||||
if (invoice.qbo_id && invoice.customer_qbo_id) {
|
if (invoice.qbo_id && invoice.customer_qbo_id) {
|
||||||
try {
|
try {
|
||||||
await recordStripePaymentInQbo(invoice, amountReceived, methodLabel, stripeFee,
|
await recordStripePaymentInQbo(
|
||||||
status.details.paymentIntentId || '');
|
invoice, amountReceived, methodLabel, stripeFee,
|
||||||
|
status.details.paymentIntentId || '',
|
||||||
|
{ source: 'auto-polled' } // ← NEU: ein zusätzlicher Optionen-Parameter
|
||||||
|
);
|
||||||
} catch (qboErr) {
|
} catch (qboErr) {
|
||||||
console.error(` ⚠️ QBO booking failed for #${invoice.invoice_number}:`, qboErr.message);
|
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
|
* Start the polling scheduler
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user