diff --git a/src/routes/invoices.js b/src/routes/invoices.js index 37ac2e5..3d3f5ee 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -14,6 +14,7 @@ const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-servic const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); const { sendInvoiceEmail } = require('../services/email-service'); const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service'); +const { recordStripePaymentInQbo } = require('../services/qbo-service'); function calculateNextRecurringDate(invoiceDate, interval) { const d = new Date(invoiceDate); @@ -1163,18 +1164,11 @@ router.post('/:id/check-payment', async (req, res) => { await deactivatePaymentLink(invoice.stripe_payment_link_id); // 4. QBO: Record Payment + Expense (if QBO-linked) - let qboResult = null; - if (invoice.qbo_id && invoice.customer_qbo_id) { - try { - qboResult = await recordStripePaymentInQbo( - invoice, amountReceived, methodLabel, stripeFee, - result.details.paymentIntentId || '' - ); - } catch (qboErr) { - console.error(`⚠️ QBO booking failed for Invoice #${invoice.invoice_number}:`, qboErr.message); - qboResult = { error: qboErr.message }; - } - } + qboResult = await recordStripePaymentInQbo( + invoice, amountReceived, methodLabel, stripeFee, + result.details.paymentIntentId || '' + // kein source-Parameter β€” default ist 'manual' + ); 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 router.patch('/:id/sent-dates', async (req, res) => { const { id } = req.params; diff --git a/src/services/qbo-service.js b/src/services/qbo-service.js index bb57154..c0dd991 100644 --- a/src/services/qbo-service.js +++ b/src/services/qbo-service.js @@ -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 }; \ No newline at end of file diff --git a/src/services/stripe-poll-service.js b/src/services/stripe-poll-service.js index c81bd23..a6cf580 100644 --- a/src/services/stripe-poll-service.js +++ b/src/services/stripe-poll-service.js @@ -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 */