kotak-payment-gateway
Version:
Kotak Bank Payment Gateway - Automated payment verification using bank statement scraping
770 lines (648 loc) • 29.7 kB
JavaScript
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;