import type { Payment } from '../plugin/types/payments' import type { PaymentProvider, ProviderData } from '../plugin/types/index' import type { BillingPluginConfig } from '../plugin/config' import type { Payload } from 'payload' import { handleWebhookError, logWebhookEvent } from './utils' import { isValidAmount, isValidCurrencyCode } from './currency' import { createContextLogger } from '../utils/logger' const TestModeWarningSymbol = Symbol('TestModeWarning') const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis const setTestModeWarning = () => ((globalThis)[TestModeWarningSymbol] = true) // Request validation schemas interface ProcessPaymentRequest { paymentId: string scenarioId: string method: PaymentMethod } // Validation functions function validateProcessPaymentRequest(body: any): { isValid: boolean; data?: ProcessPaymentRequest; error?: string } { if (!body || typeof body !== 'object') { return { isValid: false, error: 'Request body must be a valid JSON object' } } const { paymentId, scenarioId, method } = body if (!paymentId || typeof paymentId !== 'string') { return { isValid: false, error: 'paymentId is required and must be a string' } } if (!scenarioId || typeof scenarioId !== 'string') { return { isValid: false, error: 'scenarioId is required and must be a string' } } if (!method || typeof method !== 'string') { return { isValid: false, error: 'method is required and must be a string' } } // Validate method is a valid payment method const validMethods: PaymentMethod[] = ['ideal', 'creditcard', 'paypal', 'applepay', 'banktransfer'] if (!validMethods.includes(method as PaymentMethod)) { return { isValid: false, error: `method must be one of: ${validMethods.join(', ')}` } } return { isValid: true, data: { paymentId, scenarioId, method: method as PaymentMethod } } } function validatePaymentId(paymentId: string): { isValid: boolean; error?: string } { if (!paymentId || typeof paymentId !== 'string') { return { isValid: false, error: 'Payment ID is required and must be a string' } } // Validate payment ID format (should match test payment ID pattern) if (!paymentId.startsWith('test_pay_')) { return { isValid: false, error: 'Invalid payment ID format' } } return { isValid: true } } // Utility function to safely extract collection name function getPaymentsCollectionName(pluginConfig: BillingPluginConfig): string { if (typeof pluginConfig.collections?.payments === 'string') { return pluginConfig.collections.payments } return 'payments' } // Enhanced error handling utility for database operations async function updatePaymentInDatabase( payload: Payload, sessionId: string, status: Payment['status'], providerData: ProviderData, pluginConfig: BillingPluginConfig ): Promise<{ success: boolean; error?: string }> { try { const paymentsCollection = getPaymentsCollectionName(pluginConfig) const payments = await payload.find({ collection: paymentsCollection as any, // PayloadCMS collection type constraint where: { providerId: { equals: sessionId } }, limit: 1 }) if (payments.docs.length === 0) { return { success: false, error: 'Payment not found in database' } } await payload.update({ collection: paymentsCollection as any, // PayloadCMS collection type constraint id: payments.docs[0].id, data: { status, providerData } }) return { success: true } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown database error' const logger = createContextLogger(payload, 'Test Provider') logger.error('Database update failed:', errorMessage) return { success: false, error: errorMessage } } } export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending' export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer' export interface PaymentScenario { id: string name: string description: string outcome: PaymentOutcome delay?: number // Delay in milliseconds before processing method?: PaymentMethod } export interface TestProviderConfig { enabled: boolean scenarios?: PaymentScenario[] customUiRoute?: string testModeIndicators?: { showWarningBanners?: boolean showTestBadges?: boolean consoleWarnings?: boolean } defaultDelay?: number baseUrl?: string } export interface TestProviderConfigResponse { enabled: boolean scenarios: PaymentScenario[] methods: Array<{ id: string name: string icon: string }> testModeIndicators: { showWarningBanners: boolean showTestBadges: boolean consoleWarnings: boolean } defaultDelay: number customUiRoute: string } // Properly typed session interface export interface TestPaymentSession { id: string payment: Partial scenario?: PaymentScenario method?: PaymentMethod createdAt: Date status: PaymentOutcome } // Use the proper BillingPluginConfig type // Default payment scenarios const DEFAULT_SCENARIOS: PaymentScenario[] = [ { id: 'instant-success', name: 'Instant Success', description: 'Payment succeeds immediately', outcome: 'paid', delay: 0 }, { id: 'delayed-success', name: 'Delayed Success', description: 'Payment succeeds after a delay', outcome: 'paid', delay: 3000 }, { id: 'cancelled-payment', name: 'Cancelled Payment', description: 'User cancels the payment', outcome: 'cancelled', delay: 1000 }, { id: 'declined-payment', name: 'Declined Payment', description: 'Payment is declined by the provider', outcome: 'failed', delay: 2000 }, { id: 'expired-payment', name: 'Expired Payment', description: 'Payment expires before completion', outcome: 'expired', delay: 5000 }, { id: 'pending-payment', name: 'Pending Payment', description: 'Payment remains in pending state', outcome: 'pending', delay: 1500 } ] // Payment method configurations const PAYMENT_METHODS: Record = { ideal: { name: 'iDEAL', icon: '๐Ÿฆ' }, creditcard: { name: 'Credit Card', icon: '๐Ÿ’ณ' }, paypal: { name: 'PayPal', icon: '๐Ÿ…ฟ๏ธ' }, applepay: { name: 'Apple Pay', icon: '๐ŸŽ' }, banktransfer: { name: 'Bank Transfer', icon: '๐Ÿ›๏ธ' } } // In-memory storage for test payment sessions const testPaymentSessions = new Map() export const testProvider = (testConfig: TestProviderConfig) => { if (!testConfig.enabled) { throw new Error('Test provider is disabled') } const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000') const uiRoute = testConfig.customUiRoute || '/test-payment' // Test mode warnings will be logged in onInit when payload is available return { key: 'test', onConfig: (config, pluginConfig) => { // Register test payment UI endpoint config.endpoints = [ ...(config.endpoints || []), { path: '/payload-billing/test/payment/:id', method: 'get', handler: async (req) => { // Extract payment ID from URL path const urlParts = req.url?.split('/') || [] const paymentId = urlParts[urlParts.length - 1] if (!paymentId) { return new Response(JSON.stringify({ error: 'Payment ID required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } // Validate payment ID format const validation = validatePaymentId(paymentId) if (!validation.isValid) { return new Response(JSON.stringify({ error: validation.error }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } const session = testPaymentSessions.get(paymentId) if (!session) { return new Response(JSON.stringify({ error: 'Payment session not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }) } // Generate test payment UI const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig) return new Response(html, { headers: { 'Content-Type': 'text/html' } }) } }, { path: '/payload-billing/test/config', method: 'get', handler: async (req) => { const response: TestProviderConfigResponse = { enabled: testConfig.enabled, scenarios: scenarios, methods: Object.entries(PAYMENT_METHODS).map(([id, method]) => ({ id, name: method.name, icon: method.icon })), testModeIndicators: { showWarningBanners: testConfig.testModeIndicators?.showWarningBanners ?? true, showTestBadges: testConfig.testModeIndicators?.showTestBadges ?? true, consoleWarnings: testConfig.testModeIndicators?.consoleWarnings ?? true }, defaultDelay: testConfig.defaultDelay || 1000, customUiRoute: uiRoute } return new Response(JSON.stringify(response), { headers: { 'Content-Type': 'application/json' } }) } }, { path: '/payload-billing/test/process', method: 'post', handler: async (req) => { try { const payload = req.payload const body = await req.json?.() || {} // Validate request body const validation = validateProcessPaymentRequest(body) if (!validation.isValid) { return new Response(JSON.stringify({ error: validation.error }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } const { paymentId, scenarioId, method } = validation.data! const session = testPaymentSessions.get(paymentId) if (!session) { return new Response(JSON.stringify({ error: 'Payment session not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }) } const scenario = scenarios.find(s => s.id === scenarioId) if (!scenario) { return new Response(JSON.stringify({ error: 'Invalid scenario ID' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } // Update session with selected scenario and method session.scenario = scenario session.method = method session.status = 'pending' // Process payment after delay setTimeout(() => { processTestPayment(payload, session, pluginConfig).catch(async (error) => { const logger = createContextLogger(payload, 'Test Provider') logger.error('Failed to process payment:', error) // Ensure session status is updated consistently session.status = 'failed' // Create error provider data const errorProviderData: ProviderData = { raw: { error: error instanceof Error ? error.message : 'Unknown processing error', processedAt: new Date().toISOString(), testMode: true }, timestamp: new Date().toISOString(), provider: 'test' } // Update payment record in database with enhanced error handling const dbResult = await updatePaymentInDatabase( payload, session.id, 'failed', errorProviderData, pluginConfig ) if (!dbResult.success) { const logger = createContextLogger(payload, 'Test Provider') logger.error('Database error during failure handling:', dbResult.error) // Even if database update fails, we maintain session consistency } else { logWebhookEvent('Test Provider', `Payment ${session.id} marked as failed after processing error`, undefined, req.payload) } }) }, scenario.delay || testConfig.defaultDelay || 1000) return new Response(JSON.stringify({ success: true, status: 'processing', scenario: scenario.name, delay: scenario.delay || testConfig.defaultDelay || 1000 }), { headers: { 'Content-Type': 'application/json' } }) } catch (error) { return handleWebhookError('Test Provider', error, 'Failed to process test payment', req.payload) } } }, { path: '/payload-billing/test/status/:id', method: 'get', handler: (req) => { // Extract payment ID from URL path const urlParts = req.url?.split('/') || [] const paymentId = urlParts[urlParts.length - 1] if (!paymentId) { return new Response(JSON.stringify({ error: 'Payment ID required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } // Validate payment ID format const validation = validatePaymentId(paymentId) if (!validation.isValid) { return new Response(JSON.stringify({ error: validation.error }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } const session = testPaymentSessions.get(paymentId) if (!session) { return new Response(JSON.stringify({ error: 'Payment session not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }) } return new Response(JSON.stringify({ status: session.status, scenario: session.scenario?.name, method: session.method ? PAYMENT_METHODS[session.method]?.name : undefined }), { headers: { 'Content-Type': 'application/json' } }) } } ] }, onInit: (payload: Payload) => { logWebhookEvent('Test Provider', 'Test payment provider initialized', undefined, payload) // Log test mode warnings if enabled if (testConfig.testModeIndicators?.consoleWarnings !== false && !hasGivenTestModeWarning()) { setTestModeWarning() const logger = createContextLogger(payload, 'Test Provider') logger.warn('๐Ÿงช Payment system is running in test mode') } // Clean up old sessions periodically (older than 1 hour) setInterval(() => { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) testPaymentSessions.forEach((session, id) => { if (session.createdAt < oneHourAgo) { testPaymentSessions.delete(id) } }) }, 10 * 60 * 1000) // Clean every 10 minutes }, initPayment: async (payload, payment) => { // Validate required fields if (!payment.amount) { throw new Error('Amount is required') } if (!payment.currency) { throw new Error('Currency is required') } // Validate amount if (!isValidAmount(payment.amount)) { throw new Error('Invalid amount: must be a positive integer within reasonable limits') } // Validate currency code if (!isValidCurrencyCode(payment.currency)) { throw new Error('Invalid currency: must be a 3-letter ISO code') } // Generate unique test payment ID const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Create test payment session const session = { id: testPaymentId, payment: { ...payment }, createdAt: new Date(), status: 'pending' as PaymentOutcome } testPaymentSessions.set(testPaymentId, session) // Set provider ID and data payment.providerId = testPaymentId const providerData: ProviderData = { raw: { id: testPaymentId, amount: payment.amount, currency: payment.currency, description: payment.description, status: 'pending', testMode: true, paymentUrl: `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`, scenarios: scenarios.map(s => ({ id: s.id, name: s.name, description: s.description })), methods: Object.entries(PAYMENT_METHODS).map(([key, value]) => ({ id: key, name: value.name, icon: value.icon })) }, timestamp: new Date().toISOString(), provider: 'test' } payment.providerData = providerData return payment }, } satisfies PaymentProvider } // Helper function to process test payment based on scenario async function processTestPayment( payload: Payload, session: TestPaymentSession, pluginConfig: BillingPluginConfig ): Promise { try { if (!session.scenario) return // Map scenario outcome to payment status let finalStatus: Payment['status'] = 'pending' switch (session.scenario.outcome) { case 'paid': finalStatus = 'succeeded' break case 'failed': finalStatus = 'failed' break case 'cancelled': finalStatus = 'canceled' break case 'expired': finalStatus = 'canceled' // Treat expired as canceled break case 'pending': finalStatus = 'pending' break } // Update session status session.status = session.scenario.outcome // Update payment with final status and provider data const updatedProviderData: ProviderData = { raw: { ...session.payment, id: session.id, status: session.scenario.outcome, scenario: session.scenario.name, method: session.method, processedAt: new Date().toISOString(), testMode: true }, timestamp: new Date().toISOString(), provider: 'test' } // Use the utility function for database operations const dbResult = await updatePaymentInDatabase( payload, session.id, finalStatus, updatedProviderData, pluginConfig ) if (dbResult.success) { logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`, undefined, payload) } else { const logger = createContextLogger(payload, 'Test Provider') logger.error('Failed to update payment in database:', dbResult.error) // Update session status to indicate database error, but don't throw // This allows the UI to still show the intended test result session.status = 'failed' throw new Error(`Database update failed: ${dbResult.error}`) } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown processing error' const logger = createContextLogger(payload, 'Test Provider') logger.error('Failed to process payment:', errorMessage) session.status = 'failed' throw error // Re-throw to be handled by the caller } } // Helper function to generate test payment UI function generateTestPaymentUI( session: TestPaymentSession, scenarios: PaymentScenario[], uiRoute: string, baseUrl: string, testConfig: TestProviderConfig ): string { const payment = session.payment const testModeIndicators = testConfig.testModeIndicators || {} return ` Test Payment - ${payment.description || 'Payment'}
${testModeIndicators.showWarningBanners !== false ? `
๐Ÿงช TEST MODE - This is a simulated payment for development purposes
` : ''}
Test Payment Checkout ${testModeIndicators.showTestBadges !== false ? 'Test' : ''}
${payment.currency?.toUpperCase()} ${payment.amount ? (payment.amount / 100).toFixed(2) : '0.00'}
${payment.description ? `
${payment.description}
` : ''}
๐Ÿ’ณ Select Payment Method
${Object.entries(PAYMENT_METHODS).map(([key, method]) => `
${method.icon}
${method.name}
`).join('')}
๐ŸŽญ Select Test Scenario
${scenarios.map(scenario => `
${scenario.name}
${scenario.description}
`).join('')}
` }