UNPKG

kotak-payment-gateway

Version:

Kotak Bank Payment Gateway - Automated payment verification using bank statement scraping

770 lines (648 loc) 29.7 kB
const { download, closeScraper, isDownloadInProgress, setProcessingStatusChecker } = require('./scraper'); const DatabaseManager = require('./database/connection'); const CSVTransactionParser = require('./parser/csvParser'); const PaymentManager = require('./payment/manager'); const glob = require('glob'); const path = require('path'); const cron = require('node-cron'); class KotakPaymentGateway extends require('events') { constructor(options) { super(); this.config = { kotakUsername: options.kotakUsername, kotakPassword: options.kotakPassword, email: options.email, emailPassword: options.emailPassword, mongoUrl: options.mongoUrl, autoUpdate: options.autoUpdate || true, updateFrequency: options.updateFrequency || 'hourly', headless: options.headless !== false, // Default to true defaultPaymentTimeout: options.defaultPaymentTimeout || 3600000, // 1 hour default // UPI Configuration upiId: options.upiId || null, businessName: options.businessName || 'Payment' }; this.db = null; this.parser = new CSVTransactionParser(); this.paymentManager = null; this.updateJob = null; this.initialized = false; this.processingCSV = false; // Validate required config this.validateConfig(); } validateConfig() { const required = ['kotakUsername', 'kotakPassword', 'email', 'emailPassword', 'mongoUrl']; const missing = required.filter(key => !this.config[key]); if (missing.length > 0) { throw new Error(`Missing required configuration: ${missing.join(', ')}`); } } /** * Initialize the payment gateway */ async init(attempt = 1) { try { console.log('Initializing Kotak Payment Gateway...'); // Initialize database connection this.db = new DatabaseManager(this.config.mongoUrl); await this.db.connect(); // Initialize payment manager this.paymentManager = new PaymentManager(this.db, { upiId: this.config.upiId, businessName: this.config.businessName }); this.setupPaymentEventHandlers(); // Set up processing status checker for scraper setProcessingStatusChecker(() => this.isProcessingCSV()); // Verify credentials by attempting to download statements console.log('Verifying credentials...'); const testDownload = await this.downloadStatements(); if (!testDownload) { throw new Error('Failed'); } // Start auto-update scheduler if enabled if (this.config.autoUpdate) { this.startAutoUpdate(); } this.initialized = true; console.log('Kotak Payment Gateway initialized successfully'); // Emit ready event this.emit('ready'); } catch (error) { if (attempt < 3) { console.warn(`Initialization attempt ${attempt} failed: ${error.message}. Retrying...`); return await this.init(attempt + 1); } else { console.error('Initialization failed:', error.message); await this.close(); throw error; } } } /** * Setup payment event handlers */ setupPaymentEventHandlers() { // Forward payment manager events to gateway events this.paymentManager.on('payment.created', (payment) => { this.emit('payment.created', payment); }); this.paymentManager.on('payment.verified', (data) => { this.emit('payment.verified', data); }); this.paymentManager.on('payment.expired', (data) => { this.emit('payment.expired', data); }); this.paymentManager.on('payment.cancelled', (data) => { this.emit('payment.cancelled', data); }); this.paymentManager.on('payment.status_changed', (data) => { this.emit('payment.status_changed', data); }); } /** * Create a payment expectation * @param {Object} paymentData - Payment data * @returns {Promise<Object>} Created payment record */ async createPayment(paymentData) { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } // Add default timeout if not provided if (!paymentData.timeout) { paymentData.timeout = this.config.defaultPaymentTimeout; } // Automatically add UPI details if gateway has UPI configuration if (this.config.upiId) { paymentData.upiId = this.config.upiId; paymentData.payeeName = this.config.businessName; } return await this.paymentManager.createPayment(paymentData); } /** * Verify a payment by order ID and amount * @param {string} orderId - Order ID to verify * @param {number} amount - Expected payment amount * @param {string} transactionId - Optional transaction ID for verification (actual UPI transaction ID from description, e.g., "562066460598") * @returns {Promise<Object>} Verification result */ async verifyPayment(orderId, amount, transactionId = null) { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } if (orderId.slice(0, 5) !== 'order') { throw new Error('Invalid order ID format. Must start with "order".'); } try { console.log(`Verifying payment for order: ${orderId}, amount: ${amount}${transactionId ? `, transactionId: ${transactionId}` : ''}`); // Track verification attempt if payment exists const existingPayment = await this.paymentManager.getPayment(orderId); if (existingPayment) { await this.paymentManager.incrementVerificationAttempts(orderId); } let transactions = []; // If transaction ID is provided, prioritize it over order ID if (transactionId) { console.log(`Searching by transaction ID: ${transactionId}`); transactions = await this.db.findTransactionsByTransactionId(transactionId, amount); if (transactions.length > 0) { console.log(`Payment found in database for transaction ID: ${transactionId}`); // Update payment status if exists if (existingPayment && existingPayment.status === 'pending') { await this.paymentManager.updatePaymentStatus(orderId, 'verified', { transactionId: transactions[0]._id, transactionDate: transactions[0].transactionDate, verificationMethod: 'transaction_id' }); } return { verified: true, orderId, amount, transaction: transactions[0], source: 'database_by_transaction_id', verificationMethod: 'transaction_id' }; } } // First, check existing transactions in database by order ID transactions = await this.db.findTransactionsByOrderId(orderId, amount); if (transactions.length > 0) { console.log(`Payment found in database for order: ${orderId}`); // Update payment status if exists if (existingPayment && existingPayment.status === 'pending') { await this.paymentManager.updatePaymentStatus(orderId, 'verified', { transactionId: transactions[0]._id, transactionDate: transactions[0].transactionDate, verificationMethod: 'order_id' }); } return { verified: true, orderId, amount, transaction: transactions[0], source: 'database', verificationMethod: 'order_id' }; } // If not found, trigger fresh download console.log('Transaction not found in database, downloading fresh statements...'); const downloaded = await this.downloadStatements(); // If download was skipped due to concurrent request, wait a bit and check database again if (downloaded && isDownloadInProgress()) { console.log('Download was already in progress, waiting for completion...'); // Wait for the concurrent download to complete while (isDownloadInProgress()) { await new Promise(resolve => setTimeout(resolve, 1000)); } // Check database again after concurrent download completes if (transactionId) { const retryTransactions = await this.db.findTransactionsByTransactionId(transactionId, amount); if (retryTransactions.length > 0) { console.log(`Payment found after concurrent download for transaction ID: ${transactionId}`); if (existingPayment && existingPayment.status === 'pending') { await this.paymentManager.updatePaymentStatus(orderId, 'verified', { transactionId: retryTransactions[0]._id, transactionDate: retryTransactions[0].transactionDate, verificationMethod: 'transaction_id' }); } return { verified: true, orderId, amount, transaction: retryTransactions[0], source: 'concurrent_download_by_transaction_id', verificationMethod: 'transaction_id' }; } } const retryTransactions = await this.db.findTransactionsByOrderId(orderId, amount); if (retryTransactions.length > 0) { console.log(`Payment found after concurrent download for order: ${orderId}`); if (existingPayment && existingPayment.status === 'pending') { await this.paymentManager.updatePaymentStatus(orderId, 'verified', { transactionId: retryTransactions[0]._id, transactionDate: retryTransactions[0].transactionDate, verificationMethod: 'order_id' }); } return { verified: true, orderId, amount, transaction: retryTransactions[0], source: 'concurrent_download', verificationMethod: 'order_id' }; } } if (downloaded) { // Process the newly downloaded CSV const processedCount = await this.processLatestCSV(); if (processedCount > 0) { // If transaction ID was provided, check again with it first if (transactionId) { transactions = await this.db.findTransactionsByTransactionId(transactionId, amount); if (transactions.length > 0) { console.log(`Payment found after fresh download for transaction ID: ${transactionId}`); // Update payment status if exists if (existingPayment && existingPayment.status === 'pending') { await this.paymentManager.updatePaymentStatus(orderId, 'verified', { transactionId: transactions[0]._id, transactionDate: transactions[0].transactionDate, verificationMethod: 'transaction_id' }); } return { verified: true, orderId, amount, transaction: transactions[0], source: 'fresh_download_by_transaction_id', verificationMethod: 'transaction_id' }; } } // Check again after processing by order ID transactions = await this.db.findTransactionsByOrderId(orderId, amount); if (transactions.length > 0) { console.log(`Payment found after fresh download for order: ${orderId}`); // Update payment status if exists if (existingPayment && existingPayment.status === 'pending') { await this.paymentManager.updatePaymentStatus(orderId, 'verified', { transactionId: transactions[0]._id, transactionDate: transactions[0].transactionDate, verificationMethod: 'order_id' }); } return { verified: true, orderId, amount, transaction: transactions[0], source: 'fresh_download', verificationMethod: 'order_id' }; } } } // Payment not found console.log(`Payment not found for order: ${orderId}${transactionId ? ` and transaction ID: ${transactionId}` : ''}`); return { verified: false, orderId, amount, transactionId, transaction: null, source: 'not_found' }; } catch (error) { console.error('Payment verification failed:', error.message); throw error; } } /** * Get payment status * @param {string} orderId - Order ID * @returns {Promise<Object|null>} Payment record */ async getPaymentStatus(orderId) { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } return await this.paymentManager.getPayment(orderId); } /** * Cancel a payment * @param {string} orderId - Order ID * @returns {Promise<boolean>} Success status */ async cancelPayment(orderId) { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } return await this.paymentManager.cancelPayment(orderId); } /** * Get all pending payments * @returns {Array} Array of pending payments */ getPendingPayments() { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } return this.paymentManager.getAllPendingPayments(); } /** * Download bank statements * @returns {Promise<boolean>} Success status */ async downloadStatements() { // Prevent concurrent downloads - check scraper's status if (isDownloadInProgress()) { console.log('Download already in progress, skipping duplicate request'); return true; // Return true as the ongoing download will handle verification } try { console.log('Starting statement download...'); const emailConfig = { email: this.config.email, emailPassword: this.config.emailPassword }; const success = await download( this.config.kotakUsername, this.config.kotakPassword, emailConfig, this.config.headless ); return success; } catch (error) { console.error('Statement download failed:', error.message); return false; } } /** * Check if CSV processing is currently in progress * @returns {boolean} True if processing is in progress */ isProcessingCSV() { return this.processingCSV; } /** * Process the latest CSV file * @returns {Promise<number>} Number of transactions processed */ async processLatestCSV() { // Prevent concurrent CSV processing if (this.processingCSV) { console.log('CSV processing already in progress, waiting...'); let waitTime = 0; const maxWaitTime = 30000; // Maximum 30 seconds wait while (this.processingCSV && waitTime < maxWaitTime) { await new Promise(resolve => setTimeout(resolve, 500)); waitTime += 500; if (waitTime % 5000 === 0) { // Log every 5 seconds console.log(`Still waiting for CSV processing to complete... (${waitTime/1000}s)`); } } if (this.processingCSV) { console.warn('CSV processing timeout reached, forcing reset'); this.processingCSV = false; // Force reset to prevent permanent deadlock } return 0; // Return 0 as the concurrent processing handled it } this.processingCSV = true; try { // Find the latest CSV file const csvFiles = glob.sync('*.csv', { cwd: process.cwd() }); if (csvFiles.length === 0) { console.log('No CSV files found'); // Don't call forceUpdate() here as it creates infinite recursion // Just return 0 and let the caller handle the situation return 0; } // Sort by modification time to get the latest csvFiles.sort((a, b) => { const statA = require('fs').statSync(a); const statB = require('fs').statSync(b); return statB.mtime - statA.mtime; }); const latestCSV = csvFiles[0]; console.log(`Processing CSV file: ${latestCSV}`); // Parse the CSV const transactions = await this.parser.parseCSV(latestCSV); let processedCount = 0; const newTransactions = []; // Store transactions in database for (const transaction of transactions) { try { const result = await this.db.insertTransaction(transaction); if (result.inserted) { processedCount++; newTransactions.push(transaction); } } catch (error) { console.warn('Failed to insert transaction:', error.message); } } console.log(`Processed ${processedCount} new transactions from ${transactions.length} total`); // Check pending payments against new transactions if (newTransactions.length > 0 && this.paymentManager) { await this.paymentManager.checkPendingPayments(newTransactions); } // Emit new transactions event if (newTransactions.length > 0) { this.emit('transactions.processed', { count: processedCount, transactions: newTransactions, processedAt: new Date() }); } return processedCount; } catch (error) { console.error('CSV processing failed:', error.message); return 0; } finally { this.processingCSV = false; } } /** * Get transaction history with optional filters * @param {Object} filters - Filter criteria * @returns {Promise<Array>} Array of transactions */ async getTransactionHistory(filters = {}) { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } try { return await this.db.getTransactionHistory(filters); } catch (error) { console.error('Failed to get transaction history:', error.message); return []; } } /** * Start automatic statement updates */ startAutoUpdate() { if (this.updateJob) { return; // Already running } let cronPattern; switch (this.config.updateFrequency) { case 'hourly': cronPattern = '0 * * * *'; // Every hour break; case 'daily': cronPattern = '0 0 * * *'; // Every day at midnight break; case '6hours': cronPattern = '0 */6 * * *'; // Every 6 hours break; case '30min': cronPattern = '*/30 * * * *'; // Every 30 minutes break; case '15min': cronPattern = '*/15 * * * *'; // Every 15 minutes break; case '5min': cronPattern = '*/5 * * * *'; // Every 5 minutes break; case '1min': cronPattern = '* * * * *'; // Every minute break; default: cronPattern = '*/5 * * * *'; // Default to every 5 minutes } console.log(`Starting auto-update scheduler: ${this.config.updateFrequency} (${cronPattern})`); this.updateJob = cron.schedule(cronPattern, async () => { try { console.log('Running scheduled statement update...'); const downloaded = await this.downloadStatements(); if (downloaded) { await this.processLatestCSV(); console.log('Scheduled update completed'); } else { console.log('Scheduled update failed'); } } catch (error) { console.error('Scheduled update error:', error.message); } }); console.log('Auto-update scheduler started'); } /** * Stop automatic statement updates */ stopAutoUpdate() { if (this.updateJob) { this.updateJob.destroy(); this.updateJob = null; console.log('Auto-update scheduler stopped'); } } /** * Get gateway statistics * @returns {Promise<Object>} Statistics object */ async getStats() { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } try { const stats = await this.db.getStats(); // Add real-time pending payments count if (this.paymentManager) { stats.pendingInMemory = this.paymentManager.getPendingCount(); } return stats; } catch (error) { console.error('Failed to get stats:', error.message); return null; } } /** * Force update - download and process statements immediately * @returns {Promise<Object>} Update result */ async forceUpdate() { if (!this.initialized) { throw new Error('Gateway not initialized. Call init() first.'); } try { console.log('Force updating statements...'); // Check if download is already in progress if (isDownloadInProgress()) { console.log('Download already in progress, waiting for completion...'); // Wait for the ongoing download to complete let waitTime = 0; const maxWaitTime = 120000; // Maximum 2 minutes wait const checkInterval = 1000; // Check every 1 second while (isDownloadInProgress() && waitTime < maxWaitTime) { await new Promise(resolve => setTimeout(resolve, checkInterval)); waitTime += checkInterval; if (waitTime % 10000 === 0) { // Log every 10 seconds console.log(`Still waiting for download to complete... (${waitTime/1000}s)`); } } if (isDownloadInProgress()) { console.warn('Download wait timeout reached, the download might still be in progress'); return { success: false, error: 'Download timeout - operation took too long' }; } console.log('Concurrent download completed - CSV should already be processed by the concurrent request'); // Don't process CSV again since the concurrent download request will have already processed it // Just return success indicating that fresh data is now available return { success: true, transactionsProcessed: 0, // Unknown how many were processed by concurrent request timestamp: new Date(), source: 'concurrent_download' }; } // No concurrent download, proceed with fresh download const downloaded = await this.downloadStatements(); if (!downloaded) { return { success: false, error: 'Failed to download statements' }; } const processed = await this.processLatestCSV(); return { success: true, transactionsProcessed: processed, timestamp: new Date(), source: 'fresh_download' }; } catch (error) { console.error('Force update failed:', error.message); return { success: false, error: error.message }; } } /** * Close the gateway and cleanup resources */ async close() { try { console.log('Closing Kotak Payment Gateway...'); // Stop auto-update scheduler this.stopAutoUpdate(); // Cleanup payment manager if (this.paymentManager) { this.paymentManager.cleanup(); } // Close database connection if (this.db) { await this.db.close(); } // Close scraper browser if it exists try { const { closeScraper } = require('./scraper'); await closeScraper(); console.log('Browser closed successfully'); } catch (error) { console.warn('Error closing browser:', error.message); } // Remove all listeners this.removeAllListeners(); // Reset processing state this.processingCSV = false; this.initialized = false; console.log('Gateway closed successfully'); } catch (error) { console.error('Error closing gateway:', error.message); } } } module.exports = KotakPaymentGateway;