@profullstack/payment-gateway
Version:
Unified payment gateway abstraction for multiple payment providers
951 lines (811 loc) • 29.5 kB
JavaScript
/**
* Mock Provider for Payment Gateway
*
* Implements a mock payment provider for testing purposes.
*/
import EventEmitter from 'eventemitter3';
import crypto from 'crypto';
/**
* Create a Mock provider
* @param {Object} options - Configuration options
* @param {boolean} options.simulateErrors - Whether to simulate errors
* @param {number} options.errorRate - Error rate (0-1)
* @param {number} options.delay - Delay in milliseconds
* @param {boolean} options.debug - Enable debug mode
* @returns {Object} - Mock provider
*/
export function createMockProvider(options = {}) {
// Default options
const config = {
simulateErrors: false,
errorRate: 0.1,
delay: 500,
debug: false,
...options
};
// Create event emitter for internal events
const eventEmitter = new EventEmitter();
// In-memory storage for subscriptions, payments, and customers
const subscriptions = new Map();
const payments = new Map();
const customers = new Map();
const products = new Map();
const prices = new Map();
/**
* Create a checkout session
* @param {Object} options - Checkout options
* @param {string} options.customerId - Customer ID (optional)
* @param {string} options.customerEmail - Customer email (optional)
* @param {string} options.priceId - Price ID
* @param {string} options.successUrl - Success URL
* @param {string} options.cancelUrl - Cancel URL
* @param {Object} options.metadata - Additional metadata
* @param {string} options.mode - Checkout mode (payment, subscription, setup)
* @param {string} options.currency - Currency code (default: USD)
* @param {number} options.amount - Amount in smallest currency unit (e.g., cents)
* @returns {Promise<Object>} - Checkout session
*/
async function createCheckoutSession(options = {}) {
try {
_log('Creating mock checkout session:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error creating checkout session');
}
// Create a unique session ID
const sessionId = `mock_session_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
// Get customer ID or create a new one
let customerId = options.customerId;
if (!customerId && options.customerEmail) {
// Check if customer exists
for (const [id, customer] of customers.entries()) {
if (customer.email === options.customerEmail) {
customerId = id;
break;
}
}
// Create new customer if not found
if (!customerId) {
customerId = `mock_cus_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
customers.set(customerId, {
id: customerId,
email: options.customerEmail,
name: null,
metadata: {}
});
}
}
// Create checkout session
const session = {
id: sessionId,
customerId,
customerEmail: options.customerEmail,
mode: options.mode || 'payment',
amount: options.amount || 1000,
currency: (options.currency || 'usd').toLowerCase(),
priceId: options.priceId,
status: 'pending',
paymentStatus: 'unpaid',
successUrl: options.successUrl,
cancelUrl: options.cancelUrl,
metadata: options.metadata || {},
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30 minutes
};
// Store session
payments.set(sessionId, session);
_log('Mock checkout session created:', sessionId);
// Return a standardized response
return {
id: session.id,
url: `https://mock-payment-gateway.com/checkout/${sessionId}`,
status: session.status,
customerId: session.customerId,
customerEmail: session.customerEmail,
mode: session.mode,
paymentStatus: session.paymentStatus,
amountTotal: session.amount,
currency: session.currency,
metadata: session.metadata,
expiresAt: session.expiresAt,
provider: 'mock',
providerData: {
sessionId: session.id
}
};
} catch (error) {
_log('Error creating mock checkout session:', error);
throw error;
}
}
/**
* Create a subscription
* @param {Object} options - Subscription options
* @param {string} options.customerId - Customer ID
* @param {string} options.priceId - Price ID
* @param {Object} options.metadata - Additional metadata
* @param {string} options.paymentMethodId - Payment method ID (optional)
* @returns {Promise<Object>} - Subscription
*/
async function createSubscription(options = {}) {
try {
_log('Creating mock subscription:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error creating subscription');
}
// Validate required options
if (!options.customerId) {
throw new Error('Customer ID is required');
}
// Check if customer exists
if (!customers.has(options.customerId)) {
throw new Error(`Customer ${options.customerId} not found`);
}
// Create a unique subscription ID
const subscriptionId = `mock_sub_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
// Get price details
let interval = 'month';
let intervalCount = 1;
if (options.priceId && prices.has(options.priceId)) {
const price = prices.get(options.priceId);
if (price.recurring) {
interval = price.recurring.interval;
intervalCount = price.recurring.intervalCount;
}
}
// Calculate period dates
const now = new Date();
const periodEnd = new Date(now);
if (interval === 'day') {
periodEnd.setDate(periodEnd.getDate() + intervalCount);
} else if (interval === 'week') {
periodEnd.setDate(periodEnd.getDate() + (7 * intervalCount));
} else if (interval === 'month') {
periodEnd.setMonth(periodEnd.getMonth() + intervalCount);
} else if (interval === 'year') {
periodEnd.setFullYear(periodEnd.getFullYear() + intervalCount);
}
// Create subscription
const subscription = {
id: subscriptionId,
customerId: options.customerId,
priceId: options.priceId,
status: 'active',
currentPeriodStart: now.toISOString(),
currentPeriodEnd: periodEnd.toISOString(),
cancelAtPeriodEnd: false,
canceledAt: null,
metadata: options.metadata || {},
createdAt: now.toISOString(),
items: [
{
id: `mock_si_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
priceId: options.priceId,
quantity: 1
}
]
};
// Store subscription
subscriptions.set(subscriptionId, subscription);
_log('Mock subscription created:', subscriptionId);
// Return a standardized response
return {
id: subscription.id,
customerId: subscription.customerId,
status: subscription.status,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
canceledAt: subscription.canceledAt,
metadata: subscription.metadata,
items: subscription.items,
provider: 'mock',
providerData: {
subscriptionId: subscription.id
}
};
} catch (error) {
_log('Error creating mock subscription:', error);
throw error;
}
}
/**
* Get a subscription
* @param {Object} options - Subscription options
* @param {string} options.subscriptionId - Subscription ID
* @param {string} options.customerId - Customer ID (optional)
* @returns {Promise<Object>} - Subscription
*/
async function getSubscription(options = {}) {
try {
_log('Getting mock subscription:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error getting subscription');
}
// Validate required options
if (!options.subscriptionId && !options.customerId) {
throw new Error('Either subscription ID or customer ID is required');
}
let subscription;
if (options.subscriptionId) {
// Get subscription by ID
subscription = subscriptions.get(options.subscriptionId);
if (!subscription) {
return null;
}
} else {
// Get subscriptions for customer
const customerSubscriptions = Array.from(subscriptions.values())
.filter(sub => sub.customerId === options.customerId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
if (customerSubscriptions.length === 0) {
return null;
}
subscription = customerSubscriptions[0];
}
_log('Mock subscription retrieved:', subscription.id);
// Return a standardized response
return {
id: subscription.id,
customerId: subscription.customerId,
status: subscription.status,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
canceledAt: subscription.canceledAt,
metadata: subscription.metadata,
items: subscription.items,
provider: 'mock',
providerData: {
subscriptionId: subscription.id
}
};
} catch (error) {
_log('Error getting mock subscription:', error);
throw error;
}
}
/**
* Cancel a subscription
* @param {Object} options - Subscription options
* @param {string} options.subscriptionId - Subscription ID
* @param {boolean} options.atPeriodEnd - Whether to cancel at the end of the billing period
* @returns {Promise<Object>} - Cancellation result
*/
async function cancelSubscription(options = {}) {
try {
_log('Canceling mock subscription:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error canceling subscription');
}
// Validate required options
if (!options.subscriptionId) {
throw new Error('Subscription ID is required');
}
// Get subscription
const subscription = subscriptions.get(options.subscriptionId);
if (!subscription) {
throw new Error(`Subscription ${options.subscriptionId} not found`);
}
// Update subscription
if (options.atPeriodEnd) {
subscription.cancelAtPeriodEnd = true;
} else {
subscription.status = 'canceled';
subscription.canceledAt = new Date().toISOString();
}
_log('Mock subscription canceled:', subscription.id);
// Return a standardized response
return {
id: subscription.id,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
canceledAt: subscription.canceledAt,
provider: 'mock',
success: true
};
} catch (error) {
_log('Error canceling mock subscription:', error);
throw error;
}
}
/**
* Handle a webhook event
* @param {Object} options - Webhook options
* @param {string} options.body - Webhook body
* @param {Object} options.headers - Webhook headers
* @returns {Promise<Object>} - Webhook handling result
*/
async function handleWebhook(options = {}) {
try {
_log('Handling mock webhook:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error handling webhook');
}
// Parse webhook body
const body = typeof options.body === 'string' ? JSON.parse(options.body) : options.body;
// Validate webhook data
if (!body.type || !body.data) {
throw new Error('Invalid webhook data');
}
let result;
// Process webhook based on type
switch (body.type) {
case 'checkout.session.completed': {
const sessionId = body.data.id;
const session = payments.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
// Update session
session.status = 'completed';
session.paymentStatus = 'paid';
// Create subscription if mode is subscription
if (session.mode === 'subscription' && session.priceId) {
const subscriptionId = `mock_sub_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
// Get price details
let interval = 'month';
let intervalCount = 1;
if (prices.has(session.priceId)) {
const price = prices.get(session.priceId);
if (price.recurring) {
interval = price.recurring.interval;
intervalCount = price.recurring.intervalCount;
}
}
// Calculate period dates
const now = new Date();
const periodEnd = new Date(now);
if (interval === 'day') {
periodEnd.setDate(periodEnd.getDate() + intervalCount);
} else if (interval === 'week') {
periodEnd.setDate(periodEnd.getDate() + (7 * intervalCount));
} else if (interval === 'month') {
periodEnd.setMonth(periodEnd.getMonth() + intervalCount);
} else if (interval === 'year') {
periodEnd.setFullYear(periodEnd.getFullYear() + intervalCount);
}
// Create subscription
const subscription = {
id: subscriptionId,
customerId: session.customerId,
priceId: session.priceId,
status: 'active',
currentPeriodStart: now.toISOString(),
currentPeriodEnd: periodEnd.toISOString(),
cancelAtPeriodEnd: false,
canceledAt: null,
metadata: session.metadata,
createdAt: now.toISOString(),
items: [
{
id: `mock_si_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
priceId: session.priceId,
quantity: 1
}
]
};
// Store subscription
subscriptions.set(subscriptionId, subscription);
// Update session with subscription ID
session.subscriptionId = subscriptionId;
_log('Mock subscription created from checkout:', subscriptionId);
}
result = {
event: 'checkout.completed',
customerId: session.customerId,
customerEmail: session.customerEmail,
sessionId: session.id,
subscriptionId: session.subscriptionId,
amount: session.amount,
currency: session.currency,
metadata: session.metadata,
mode: session.mode,
status: session.status,
paymentStatus: session.paymentStatus
};
break;
}
case 'invoice.paid': {
const subscriptionId = body.data.subscription;
const subscription = subscriptions.get(subscriptionId);
if (!subscription) {
throw new Error(`Subscription ${subscriptionId} not found`);
}
// Update subscription period
const currentPeriodStart = new Date(subscription.currentPeriodEnd);
const currentPeriodEnd = new Date(subscription.currentPeriodEnd);
// Get price details
let interval = 'month';
let intervalCount = 1;
if (subscription.priceId && prices.has(subscription.priceId)) {
const price = prices.get(subscription.priceId);
if (price.recurring) {
interval = price.recurring.interval;
intervalCount = price.recurring.intervalCount;
}
}
// Calculate new period end
if (interval === 'day') {
currentPeriodEnd.setDate(currentPeriodEnd.getDate() + intervalCount);
} else if (interval === 'week') {
currentPeriodEnd.setDate(currentPeriodEnd.getDate() + (7 * intervalCount));
} else if (interval === 'month') {
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + intervalCount);
} else if (interval === 'year') {
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + intervalCount);
}
// Update subscription
subscription.currentPeriodStart = currentPeriodStart.toISOString();
subscription.currentPeriodEnd = currentPeriodEnd.toISOString();
result = {
event: 'invoice.paid',
customerId: subscription.customerId,
subscriptionId: subscription.id,
invoiceId: `mock_inv_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
amount: body.data.amount_paid || 1000,
currency: body.data.currency || 'usd',
status: 'paid'
};
break;
}
case 'customer.subscription.updated': {
const subscriptionId = body.data.id;
const subscription = subscriptions.get(subscriptionId);
if (!subscription) {
throw new Error(`Subscription ${subscriptionId} not found`);
}
// Update subscription
if (body.data.status) {
subscription.status = body.data.status;
}
if (body.data.cancel_at_period_end !== undefined) {
subscription.cancelAtPeriodEnd = body.data.cancel_at_period_end;
}
result = {
event: 'subscription.updated',
customerId: subscription.customerId,
subscriptionId: subscription.id,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
currentPeriodEnd: subscription.currentPeriodEnd
};
break;
}
case 'customer.subscription.deleted': {
const subscriptionId = body.data.id;
const subscription = subscriptions.get(subscriptionId);
if (!subscription) {
throw new Error(`Subscription ${subscriptionId} not found`);
}
// Update subscription
subscription.status = 'canceled';
subscription.canceledAt = new Date().toISOString();
result = {
event: 'subscription.deleted',
customerId: subscription.customerId,
subscriptionId: subscription.id,
status: subscription.status,
canceledAt: subscription.canceledAt
};
break;
}
default: {
result = {
event: body.type,
data: body.data
};
}
}
_log('Mock webhook processed:', result.event);
return {
...result,
provider: 'mock',
originalEvent: body
};
} catch (error) {
_log('Error handling mock webhook:', error);
throw error;
}
}
/**
* Create a customer
* @param {Object} options - Customer options
* @param {string} options.email - Customer email
* @param {string} options.name - Customer name (optional)
* @param {Object} options.metadata - Additional metadata
* @returns {Promise<Object>} - Customer
*/
async function createCustomer(options = {}) {
try {
_log('Creating mock customer:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error creating customer');
}
// Validate required options
if (!options.email) {
throw new Error('Customer email is required');
}
// Check if customer already exists
for (const [id, customer] of customers.entries()) {
if (customer.email === options.email) {
_log('Customer already exists:', id);
return {
id: customer.id,
email: customer.email,
name: customer.name,
metadata: customer.metadata,
provider: 'mock',
providerData: {
customerId: customer.id
}
};
}
}
// Create a unique customer ID
const customerId = `mock_cus_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
// Create customer
const customer = {
id: customerId,
email: options.email,
name: options.name,
metadata: options.metadata || {},
createdAt: new Date().toISOString()
};
// Store customer
customers.set(customerId, customer);
_log('Mock customer created:', customerId);
// Return a standardized response
return {
id: customer.id,
email: customer.email,
name: customer.name,
metadata: customer.metadata,
provider: 'mock',
providerData: {
customerId: customer.id
}
};
} catch (error) {
_log('Error creating mock customer:', error);
throw error;
}
}
/**
* Get a customer
* @param {Object} options - Customer options
* @param {string} options.customerId - Customer ID
* @param {string} options.email - Customer email (optional)
* @returns {Promise<Object>} - Customer
*/
async function getCustomer(options = {}) {
try {
_log('Getting mock customer:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error getting customer');
}
// Validate required options
if (!options.customerId && !options.email) {
throw new Error('Either customer ID or email is required');
}
let customer;
if (options.customerId) {
// Get customer by ID
customer = customers.get(options.customerId);
} else {
// Get customer by email
for (const [id, cust] of customers.entries()) {
if (cust.email === options.email) {
customer = cust;
break;
}
}
}
if (!customer) {
return null;
}
_log('Mock customer retrieved:', customer.id);
// Return a standardized response
return {
id: customer.id,
email: customer.email,
name: customer.name,
metadata: customer.metadata,
provider: 'mock',
providerData: {
customerId: customer.id
}
};
} catch (error) {
_log('Error getting mock customer:', error);
throw error;
}
}
/**
* Create a product
* @param {Object} options - Product options
* @param {string} options.name - Product name
* @param {string} options.description - Product description (optional)
* @param {Object} options.metadata - Additional metadata
* @returns {Promise<Object>} - Product
*/
async function createProduct(options = {}) {
try {
_log('Creating mock product:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error creating product');
}
// Validate required options
if (!options.name) {
throw new Error('Product name is required');
}
// Create a unique product ID
const productId = `mock_prod_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
// Create product
const product = {
id: productId,
name: options.name,
description: options.description,
metadata: options.metadata || {},
createdAt: new Date().toISOString()
};
// Store product
products.set(productId, product);
_log('Mock product created:', productId);
// Return a standardized response
return {
id: product.id,
name: product.name,
description: product.description,
metadata: product.metadata,
provider: 'mock',
providerData: {
productId: product.id
}
};
} catch (error) {
_log('Error creating mock product:', error);
throw error;
}
}
/**
* Create a price
* @param {Object} options - Price options
* @param {string} options.productId - Product ID
* @param {string} options.currency - Currency code (default: USD)
* @param {number} options.unitAmount - Amount in smallest currency unit (e.g., cents)
* @param {string} options.interval - Billing interval (day, week, month, year)
* @param {number} options.intervalCount - Number of intervals (default: 1)
* @param {Object} options.metadata - Additional metadata
* @returns {Promise<Object>} - Price
*/
async function createPrice(options = {}) {
try {
_log('Creating mock price:', options);
// Simulate delay
await _delay(config.delay);
// Simulate error
if (_shouldSimulateError()) {
throw new Error('Simulated error creating price');
}
// Validate required options
if (!options.productId) {
throw new Error('Product ID is required');
}
if (!options.unitAmount) {
throw new Error('Unit amount is required');
}
// Check if product exists
if (!products.has(options.productId)) {
throw new Error(`Product ${options.productId} not found`);
}
// Create a unique price ID
const priceId = `mock_price_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
// Create price
const price = {
id: priceId,
productId: options.productId,
currency: (options.currency || 'usd').toLowerCase(),
unitAmount: options.unitAmount,
metadata: options.metadata || {},
createdAt: new Date().toISOString()
};
// Add recurring parameters if interval is provided
if (options.interval) {
price.recurring = {
interval: options.interval,
intervalCount: options.intervalCount || 1
};
}
// Store price
prices.set(priceId, price);
// Update product with default price if not set
const product = products.get(options.productId);
if (!product.defaultPrice) {
product.defaultPrice = priceId;
}
_log('Mock price created:', priceId);
// Return a standardized response
return {
id: price.id,
productId: price.productId,
currency: price.currency,
unitAmount: price.unitAmount,
recurring: price.recurring,
metadata: price.metadata,
provider: 'mock',
providerData: {
priceId: price.id
}
};
} catch (error) {
_log('Error creating mock price:', error);
throw error;
}
}
/**
* Simulate a delay
* @param {number} ms - Delay in milliseconds
* @returns {Promise<void>} - Promise that resolves after the delay
* @private
*/
function _delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Determine whether to simulate an error
* @returns {boolean} - Whether to simulate an error
* @private
*/
function _shouldSimulateError() {
return config.simulateErrors && Math.random() < config.errorRate;
}
/**
* Log debug messages
* @param {...any} args - Arguments to log
* @private
*/
function _log(...args) {
if (config.debug) {
console.log('[MockProvider]', ...args);
}
}
// Return the provider
return {
createCheckoutSession,
createSubscription,
getSubscription,
cancelSubscription,
handleWebhook,
createCustomer,
getCustomer,
createProduct,
createPrice,
on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter)
};
}
export default createMockProvider;