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' 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' // Log test mode warnings if enabled if (testConfig.testModeIndicators?.consoleWarnings !== false) { console.warn('๐Ÿงช [TEST PROVIDER] Payment system is running in test mode') } 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('Payment ID required', { status: 400 }) } const session = testPaymentSessions.get(paymentId) if (!session) { return new Response('Payment session not found', { status: 404 }) } // 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: testConfig.testModeIndicators || { showWarningBanners: true, showTestBadges: true, 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?.() || {} const { paymentId, scenarioId, method } = body as any 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' }), { 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) => { console.error('[Test Provider] Failed to process payment:', error) session.status = 'failed' // Also update the payment record in database try { const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string' ? pluginConfig.collections.payments : 'payments') as any const payments = await payload.find({ collection: paymentsCollection, where: { providerId: { equals: session.id } }, limit: 1 }) if (payments.docs.length > 0) { await payload.update({ collection: paymentsCollection, id: payments.docs[0].id, data: { status: 'failed', providerData: { raw: { error: error.message, processedAt: new Date().toISOString() }, timestamp: new Date().toISOString(), provider: 'test' } } }) } } catch (dbError) { console.error('[Test Provider] Failed to update payment in database:', dbError) } }) }, 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') } } }, { path: '/payload-billing/test/status/: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' } }) } 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: async (payload: Payload) => { logWebhookEvent('Test Provider', 'Test payment provider initialized') // 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 // Find and update the payment in the database const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string' ? pluginConfig.collections.payments : 'payments') as any const payments = await payload.find({ collection: paymentsCollection, where: { providerId: { equals: session.id } }, limit: 1 }) if (payments.docs.length > 0) { const payment = payments.docs[0] // 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' } await payload.update({ collection: paymentsCollection, id: payment.id, data: { status: finalStatus, providerData: updatedProviderData } }) logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`) } } catch (error) { console.error('[Test Provider] Failed to process payment:', error) session.status = 'failed' } } // 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('')}
` }