diff --git a/src/index.js b/src/index.js index f005d68..109679b 100644 --- a/src/index.js +++ b/src/index.js @@ -23,6 +23,7 @@ const { setBrowser } = require('./services/pdf-service'); // Import recurring invoice scheduler const { startRecurringScheduler } = require('./services/recurring-service'); +const { startStripePolling } = require('./services/stripe-poll-service'); const app = express(); const PORT = process.env.PORT || 3000; @@ -119,6 +120,7 @@ async function startServer() { // Start recurring invoice scheduler (checks every 24h) startRecurringScheduler(); + startStripePolling(); } // Graceful shutdown diff --git a/src/services/stripe-poll-service.js b/src/services/stripe-poll-service.js new file mode 100644 index 0000000..c81bd23 --- /dev/null +++ b/src/services/stripe-poll-service.js @@ -0,0 +1,238 @@ +// 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 }; \ No newline at end of file