Polling stripe payments
This commit is contained in:
@@ -23,6 +23,7 @@ const { setBrowser } = require('./services/pdf-service');
|
|||||||
|
|
||||||
// Import recurring invoice scheduler
|
// Import recurring invoice scheduler
|
||||||
const { startRecurringScheduler } = require('./services/recurring-service');
|
const { startRecurringScheduler } = require('./services/recurring-service');
|
||||||
|
const { startStripePolling } = require('./services/stripe-poll-service');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -119,6 +120,7 @@ async function startServer() {
|
|||||||
|
|
||||||
// Start recurring invoice scheduler (checks every 24h)
|
// Start recurring invoice scheduler (checks every 24h)
|
||||||
startRecurringScheduler();
|
startRecurringScheduler();
|
||||||
|
startStripePolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
238
src/services/stripe-poll-service.js
Normal file
238
src/services/stripe-poll-service.js
Normal file
@@ -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 };
|
||||||
Reference in New Issue
Block a user