// src/services/stripe-poll-service.js /** * Stripe Payment Polling Service * Periodically checks all open Stripe payment links for completed payments. * * Similar pattern to recurring-service.js: * - Runs every 4 hours (and once on startup after 2 min delay) * - Finds invoices with active Stripe links that aren't paid yet * - Checks each via Stripe API * - Records payment + QBO booking if paid */ 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 /** * Check all invoices with open Stripe payment links */ async function pollStripePayments() { console.log('💳 [STRIPE-POLL] Checking for completed Stripe payments...'); const dbClient = await pool.connect(); try { // Find all invoices with active (unpaid) Stripe links const result = await dbClient.query(` SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.stripe_payment_link_id IS NOT NULL AND i.stripe_payment_status NOT IN ('paid') AND i.paid_date IS NULL `); const openInvoices = result.rows; if (openInvoices.length === 0) { console.log('💳 [STRIPE-POLL] No open Stripe payment links to check.'); return; } console.log(`💳 [STRIPE-POLL] Checking ${openInvoices.length} invoice(s)...`); let paidCount = 0; let processingCount = 0; let errorCount = 0; for (const invoice of openInvoices) { try { const status = await checkPaymentStatus(invoice.stripe_payment_link_id); // Update status if changed if (status.status !== invoice.stripe_payment_status) { await dbClient.query( 'UPDATE invoices SET stripe_payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [status.status, invoice.id] ); } if (status.status === 'processing') { processingCount++; console.log(` ⏳ #${invoice.invoice_number}: ACH processing`); continue; } if (!status.paid) continue; // === PAID — process it === const amountReceived = status.details.amountReceived; const paymentMethod = status.details.paymentMethod; const stripeFee = status.details.stripeFee; const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card'; invoice.amount_paid = parseFloat(invoice.amount_paid) || 0; await dbClient.query('BEGIN'); // 1. Record local payment const payResult = await dbClient.query( `INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, reference_number, notes, created_at) VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP) RETURNING id`, [ `Stripe ${methodLabel}`, amountReceived, invoice.customer_id, status.details.paymentIntentId || status.details.sessionId, `Stripe ${methodLabel} — Fee: $${stripeFee.toFixed(2)} (auto-polled)` ] ); await dbClient.query( 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [payResult.rows[0].id, invoice.id, amountReceived] ); // 2. Check if fully paid const newTotalPaid = invoice.amount_paid + amountReceived; const invoiceTotal = parseFloat(invoice.total) || 0; const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01); await dbClient.query( `UPDATE invoices SET stripe_payment_status = 'paid', paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'}, payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [fullyPaid ? 'Stripe' : 'Partial', invoice.id] ); // 3. Deactivate link await deactivatePaymentLink(invoice.stripe_payment_link_id); // 4. QBO booking if (invoice.qbo_id && invoice.customer_qbo_id) { try { await recordStripePaymentInQbo(invoice, amountReceived, methodLabel, stripeFee, status.details.paymentIntentId || ''); } catch (qboErr) { console.error(` ⚠️ QBO booking failed for #${invoice.invoice_number}:`, qboErr.message); } } await dbClient.query('COMMIT'); paidCount++; console.log(` ✅ #${invoice.invoice_number}: $${amountReceived.toFixed(2)} via Stripe ${methodLabel} (Fee: $${stripeFee.toFixed(2)})`); } catch (err) { await dbClient.query('ROLLBACK').catch(() => {}); errorCount++; console.error(` ❌ #${invoice.invoice_number}: ${err.message}`); } } console.log(`💳 [STRIPE-POLL] Done: ${paidCount} paid, ${processingCount} processing, ${errorCount} errors (of ${openInvoices.length} checked)`); } catch (error) { console.error('💳 [STRIPE-POLL] Fatal error:', error.message); } finally { dbClient.release(); } } /** * 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 */ function startStripePolling() { // First check after startup delay setTimeout(() => { pollStripePayments(); }, STARTUP_DELAY_MS); // Then every 4 hours setInterval(() => { pollStripePayments(); }, POLL_INTERVAL_MS); console.log(`💳 [STRIPE-POLL] Scheduler started (every ${POLL_INTERVAL_MS / 3600000}h, first check in ${STARTUP_DELAY_MS / 1000}s)`); } module.exports = { startStripePolling, pollStripePayments };