UNPKG

@profullstack/payment-gateway

Version:

Unified payment gateway abstraction for multiple payment providers

570 lines (488 loc) 19.9 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createCryptoProvider } from '../../src/providers/crypto.js'; describe('Crypto Provider', () => { let provider; let mockExchangeRateProvider; let mockVerificationCallback; beforeEach(() => { // Mock exchange rate provider mockExchangeRateProvider = vi.fn().mockResolvedValue(20000); // 1 BTC = $20,000 // Mock verification callback mockVerificationCallback = vi.fn().mockResolvedValue(true); // Create a crypto provider with test options provider = createCryptoProvider({ wallets: { btc: 'btc_wallet_address', eth: 'eth_wallet_address', sol: 'sol_wallet_address', usdc: 'usdc_wallet_address' }, exchangeRateProviders: { btc: mockExchangeRateProvider, eth: mockExchangeRateProvider, sol: mockExchangeRateProvider, usdc: mockExchangeRateProvider }, verificationCallback: mockVerificationCallback, debug: true }); }); afterEach(() => { vi.clearAllMocks(); }); describe('Initialization', () => { it('initializes with default options', () => { const defaultProvider = createCryptoProvider(); expect(defaultProvider).toBeDefined(); expect(typeof defaultProvider.createCheckoutSession).toBe('function'); }); it('initializes with provided options', () => { expect(provider).toBeDefined(); expect(typeof provider.createCheckoutSession).toBe('function'); expect(typeof provider.createSubscription).toBe('function'); expect(typeof provider.getSubscription).toBe('function'); expect(typeof provider.cancelSubscription).toBe('function'); expect(typeof provider.handleWebhook).toBe('function'); expect(typeof provider.verifyPayment).toBe('function'); }); }); describe('Checkout Sessions', () => { it('creates a checkout session with required parameters', async () => { const session = await provider.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, // $100 coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' }); expect(session.id).toMatch(/^crypto_/); expect(session.url).toMatch(/^crypto:\/\/btc\/pay/); expect(session.status).toBe('pending'); expect(session.customerEmail).toBe('test@example.com'); expect(session.amount).toBe(100); expect(session.currency).toBe('USD'); expect(session.provider).toBe('crypto'); expect(session.providerData.coin).toBe('btc'); expect(session.providerData.cryptoAmount).toBe(0.005); // $100 / $20,000 expect(session.providerData.walletAddress).toBe('btc_wallet_address'); // Verify exchange rate provider was called expect(mockExchangeRateProvider).toHaveBeenCalledWith('btc', 'USD'); }); it('throws if customer email is missing', async () => { await expect(provider.createCheckoutSession({ amount: 100, coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' })).rejects.toThrow('Customer email is required'); }); it('throws if amount is missing', async () => { await expect(provider.createCheckoutSession({ customerEmail: 'test@example.com', coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' })).rejects.toThrow('Amount is required'); }); it('throws if coin is missing', async () => { await expect(provider.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' })).rejects.toThrow('Cryptocurrency is required'); }); it('throws if coin is invalid', async () => { await expect(provider.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, coin: 'invalid', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' })).rejects.toThrow('Invalid cryptocurrency'); }); it('throws if wallet address is not configured', async () => { const providerWithoutWallets = createCryptoProvider({ exchangeRateProviders: { btc: mockExchangeRateProvider } }); await expect(providerWithoutWallets.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' })).rejects.toThrow('No wallet address configured for btc'); }); it('throws if exchange rate provider is not configured', async () => { const providerWithoutExchangeRates = createCryptoProvider({ wallets: { btc: 'btc_wallet_address' } }); await expect(providerWithoutExchangeRates.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' })).rejects.toThrow('No exchange rate provider configured for btc'); }); }); describe('Subscriptions', () => { it('creates a monthly subscription', async () => { const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 // $10/month }); expect(subscription.id).toMatch(/^crypto_sub_/); expect(subscription.customerEmail).toBe('test@example.com'); expect(subscription.status).toBe('pending'); expect(subscription.provider).toBe('crypto'); expect(subscription.providerData.coin).toBe('btc'); expect(subscription.providerData.cryptoAmount).toBe(0.0005); // $10 / $20,000 expect(subscription.providerData.walletAddress).toBe('btc_wallet_address'); // Verify exchange rate provider was called expect(mockExchangeRateProvider).toHaveBeenCalledWith('btc', 'USD'); }); it('creates a yearly subscription', async () => { const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'yearly', coin: 'btc', amount: 100 // $100/year }); expect(subscription.id).toMatch(/^crypto_sub_/); expect(subscription.customerEmail).toBe('test@example.com'); expect(subscription.status).toBe('pending'); expect(subscription.provider).toBe('crypto'); // Check that expiration date is roughly 1 year from now const expirationDate = new Date(subscription.currentPeriodEnd); const now = new Date(); const oneYearFromNow = new Date(now); oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); // Allow for a few seconds of difference due to test execution time expect(Math.abs(expirationDate.getTime() - oneYearFromNow.getTime())).toBeLessThan(5000); }); it('throws if customer email is missing', async () => { await expect(provider.createSubscription({ planId: 'monthly', coin: 'btc', amount: 10 })).rejects.toThrow('Customer email is required'); }); it('throws if plan ID is missing', async () => { await expect(provider.createSubscription({ customerEmail: 'test@example.com', coin: 'btc', amount: 10 })).rejects.toThrow('Plan ID is required'); }); it('throws if coin is missing', async () => { await expect(provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', amount: 10 })).rejects.toThrow('Cryptocurrency is required'); }); it('throws if amount is missing', async () => { await expect(provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc' })).rejects.toThrow('Amount is required'); }); it('throws if plan ID is invalid', async () => { await expect(provider.createSubscription({ customerEmail: 'test@example.com', planId: 'invalid', coin: 'btc', amount: 10 })).rejects.toThrow('Invalid plan ID'); }); it('gets a subscription by ID', async () => { // Temporarily disable verification callback for this test const originalCallback = mockVerificationCallback; mockVerificationCallback.mockResolvedValueOnce(false); // First create a subscription const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 }); // Then get it const retrievedSubscription = await provider.getSubscription({ subscriptionId: subscription.id }); expect(retrievedSubscription.id).toBe(subscription.id); expect(retrievedSubscription.customerEmail).toBe('test@example.com'); expect(retrievedSubscription.status).toBe('pending'); expect(retrievedSubscription.provider).toBe('crypto'); // Restore original behavior mockVerificationCallback.mockImplementation(originalCallback); }); it('gets a subscription by customer email', async () => { // Temporarily disable verification callback for this test const originalCallback = mockVerificationCallback; mockVerificationCallback.mockResolvedValueOnce(false); // First create a subscription const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 }); // Then get it const retrievedSubscription = await provider.getSubscription({ customerEmail: 'test@example.com' }); expect(retrievedSubscription.id).toBe(subscription.id); expect(retrievedSubscription.customerEmail).toBe('test@example.com'); expect(retrievedSubscription.status).toBe('pending'); expect(retrievedSubscription.provider).toBe('crypto'); // Restore original behavior mockVerificationCallback.mockImplementation(originalCallback); }); it('returns null if subscription is not found', async () => { const result = await provider.getSubscription({ subscriptionId: 'non_existent' }); expect(result).toBeNull(); }); it('verifies subscription payment when getting a subscription', async () => { // First create a subscription const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 }); // Then get it, which should trigger verification const retrievedSubscription = await provider.getSubscription({ subscriptionId: subscription.id }); // Verify verification callback was called expect(mockVerificationCallback).toHaveBeenCalled(); expect(retrievedSubscription.status).toBe('active'); expect(retrievedSubscription.providerData.paymentStatus).toBe('paid'); }); it('cancels a subscription', async () => { // First create a subscription const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 }); // Then cancel it const result = await provider.cancelSubscription({ subscriptionId: subscription.id }); expect(result.id).toBe(subscription.id); expect(result.status).toBe('canceled'); expect(result.canceledAt).not.toBeNull(); expect(result.provider).toBe('crypto'); expect(result.success).toBe(true); // Verify the subscription was updated const retrievedSubscription = await provider.getSubscription({ subscriptionId: subscription.id }); expect(retrievedSubscription.status).toBe('canceled'); expect(retrievedSubscription.canceledAt).not.toBeNull(); }); it('throws if subscription ID is missing when canceling', async () => { await expect(provider.cancelSubscription({})).rejects.toThrow('Subscription ID is required'); }); it('throws if subscription is not found when canceling', async () => { await expect(provider.cancelSubscription({ subscriptionId: 'non_existent' })).rejects.toThrow('Subscription non_existent not found'); }); }); describe('Webhooks', () => { it('handles subscription payment webhook', async () => { // First create a subscription const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 }); // Then handle webhook const result = await provider.handleWebhook({ body: JSON.stringify({ address: 'btc_wallet_address', txid: 'test_transaction_id', coin: 'btc', amount: 0.0005 }) }); expect(result.event).toBe('subscription.paid'); expect(result.subscriptionId).toBe(subscription.id); expect(result.customerEmail).toBe('test@example.com'); expect(result.transactionId).toBe('test_transaction_id'); expect(result.coin).toBe('btc'); expect(result.status).toBe('active'); expect(result.provider).toBe('crypto'); // Verify the subscription was updated const retrievedSubscription = await provider.getSubscription({ subscriptionId: subscription.id }); expect(retrievedSubscription.status).toBe('active'); expect(retrievedSubscription.providerData.paymentStatus).toBe('paid'); expect(retrievedSubscription.providerData.transactionId).toBe('test_transaction_id'); }); it('handles payment webhook', async () => { // First create a checkout session const session = await provider.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' }); // Then handle webhook const result = await provider.handleWebhook({ body: JSON.stringify({ address: 'btc_wallet_address', txid: 'test_transaction_id', coin: 'btc', amount: 0.005 }) }); expect(result.event).toBe('payment.completed'); expect(result.paymentId).toBe(session.id); expect(result.customerEmail).toBe('test@example.com'); expect(result.transactionId).toBe('test_transaction_id'); expect(result.coin).toBe('btc'); expect(result.status).toBe('completed'); expect(result.provider).toBe('crypto'); }); it('throws if webhook body is missing', async () => { await expect(provider.handleWebhook({})).rejects.toThrow('Webhook body is required'); }); it('throws if webhook data is invalid', async () => { await expect(provider.handleWebhook({ body: JSON.stringify({ // Missing required fields amount: 0.005 }) })).rejects.toThrow('Invalid webhook data'); }); it('throws if no matching subscription or payment is found', async () => { await expect(provider.handleWebhook({ body: JSON.stringify({ address: 'non_existent_address', txid: 'test_transaction_id', coin: 'btc', amount: 0.005 }) })).rejects.toThrow('No matching subscription or payment found'); }); }); describe('Payment Verification', () => { it('verifies a subscription payment', async () => { // First create a subscription const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 }); // Then verify payment const result = await provider.verifyPayment({ subscriptionId: subscription.id, transactionId: 'test_transaction_id' }); expect(result).toBe(true); expect(mockVerificationCallback).toHaveBeenCalled(); // Verify the subscription was updated const retrievedSubscription = await provider.getSubscription({ subscriptionId: subscription.id }); expect(retrievedSubscription.status).toBe('active'); expect(retrievedSubscription.providerData.paymentStatus).toBe('paid'); expect(retrievedSubscription.providerData.transactionId).toBe('test_transaction_id'); }); it('verifies a payment', async () => { // First create a checkout session const session = await provider.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' }); // Then verify payment const result = await provider.verifyPayment({ paymentId: session.id, transactionId: 'test_transaction_id' }); expect(result).toBe(true); expect(mockVerificationCallback).toHaveBeenCalled(); }); it('throws if subscription ID and payment ID are missing', async () => { await expect(provider.verifyPayment({ transactionId: 'test_transaction_id' })).rejects.toThrow('Either subscription ID or payment ID is required'); }); it('throws if transaction ID is missing', async () => { await expect(provider.verifyPayment({ subscriptionId: 'test_subscription_id' })).rejects.toThrow('Transaction ID is required'); }); it('throws if subscription is not found', async () => { await expect(provider.verifyPayment({ subscriptionId: 'non_existent', transactionId: 'test_transaction_id' })).rejects.toThrow('Subscription not found'); }); }); describe('Event Emission', () => { it('emits events when subscription is paid', async () => { // Create event listener const eventHandler = vi.fn(); provider.on('subscription.paid', eventHandler); // Create subscription const subscription = await provider.createSubscription({ customerEmail: 'test@example.com', planId: 'monthly', coin: 'btc', amount: 10 }); // Verify payment await provider.verifyPayment({ subscriptionId: subscription.id, transactionId: 'test_transaction_id' }); // Verify event was emitted expect(eventHandler).toHaveBeenCalled(); expect(eventHandler.mock.calls[0][0].id).toBe(subscription.id); }); it('emits events when payment is completed', async () => { // Create event listener const eventHandler = vi.fn(); provider.on('payment.completed', eventHandler); // Create checkout session const session = await provider.createCheckoutSession({ customerEmail: 'test@example.com', amount: 100, coin: 'btc', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' }); // Verify payment await provider.verifyPayment({ paymentId: session.id, transactionId: 'test_transaction_id' }); // Verify event was emitted expect(eventHandler).toHaveBeenCalled(); expect(eventHandler.mock.calls[0][0].id).toBe(session.id); }); }); });