diff --git a/docs/test-provider-example.md b/docs/test-provider-example.md new file mode 100644 index 0000000..22c4338 --- /dev/null +++ b/docs/test-provider-example.md @@ -0,0 +1,147 @@ +# Advanced Test Provider Example + +The advanced test provider allows you to test complex payment scenarios with an interactive UI for development purposes. + +## Basic Configuration + +```typescript +import { billingPlugin, testProvider } from '@xtr-dev/payload-billing' + +// Configure the test provider +const testProviderConfig = { + enabled: true, // Enable the test provider + defaultDelay: 2000, // Default delay in milliseconds + baseUrl: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000', + customUiRoute: '/test-payment', // Custom route for test payment UI + testModeIndicators: { + showWarningBanners: true, // Show warning banners in test mode + showTestBadges: true, // Show test badges + consoleWarnings: true, // Show console warnings + } +} + +// Add to your payload config +export default buildConfig({ + plugins: [ + billingPlugin({ + providers: [ + testProvider(testProviderConfig) + ] + }) + ] +}) +``` + +## Custom Scenarios + +You can define custom payment scenarios: + +```typescript +const customScenarios = [ + { + id: 'quick-success', + name: 'Quick Success', + description: 'Payment succeeds in 1 second', + outcome: 'paid' as const, + delay: 1000, + method: 'creditcard' as const + }, + { + id: 'network-timeout', + name: 'Network Timeout', + description: 'Simulates network timeout', + outcome: 'failed' as const, + delay: 10000 + }, + { + id: 'user-abandonment', + name: 'User Abandonment', + description: 'User closes payment window', + outcome: 'cancelled' as const, + delay: 5000 + } +] + +const testProviderConfig = { + enabled: true, + scenarios: customScenarios, + // ... other config +} +``` + +## Available Payment Outcomes + +- `paid` - Payment succeeds +- `failed` - Payment fails +- `cancelled` - Payment is cancelled by user +- `expired` - Payment expires +- `pending` - Payment remains pending + +## Available Payment Methods + +- `ideal` - iDEAL (Dutch banking) +- `creditcard` - Credit/Debit Cards +- `paypal` - PayPal +- `applepay` - Apple Pay +- `banktransfer` - Bank Transfer + +## Using the Test UI + +1. Create a payment using the test provider +2. The payment will return a `paymentUrl` in the provider data +3. Navigate to this URL to access the interactive test interface +4. Select a payment method and scenario +5. Click "Process Test Payment" to simulate the payment +6. The payment status will update automatically based on the selected scenario + +## React Components + +Use the provided React components in your admin interface: + +```tsx +import { TestModeWarningBanner, TestModeBadge, TestPaymentControls } from '@xtr-dev/payload-billing/client' + +// Show warning banner when in test mode + + +// Add test badge to payment status +
+ Payment Status: {status} + +
+ +// Payment testing controls + console.log('Selected scenario:', scenario)} + onMethodSelect={(method) => console.log('Selected method:', method)} +/> +``` + +## API Endpoints + +The test provider automatically registers these endpoints: + +- `GET /api/payload-billing/test/payment/:id` - Test payment UI +- `POST /api/payload-billing/test/process` - Process test payment +- `GET /api/payload-billing/test/status/:id` - Get payment status + +## Development Tips + +1. **Console Warnings**: Keep `consoleWarnings: true` to get notifications about test mode +2. **Visual Indicators**: Use warning banners and badges to clearly mark test payments +3. **Custom Scenarios**: Create scenarios that match your specific use cases +4. **Automated Testing**: Use the test provider in your e2e tests for predictable payment outcomes +5. **Method Testing**: Test different payment methods to ensure your UI handles them correctly + +## Production Safety + +The test provider includes several safety mechanisms: + +- Must be explicitly enabled with `enabled: true` +- Clearly marked with test indicators +- Console warnings when active +- Separate endpoint namespace (`/payload-billing/test/`) +- No real payment processing + +**Important**: Never use the test provider in production environments! \ No newline at end of file diff --git a/src/exports/client.tsx b/src/exports/client.tsx index 7c2facf..8a19c8a 100644 --- a/src/exports/client.tsx +++ b/src/exports/client.tsx @@ -60,9 +60,130 @@ export const PaymentStatusBadge: React.FC<{ status: string }> = ({ status }) => ) } +// Test mode indicator components +export const TestModeWarningBanner: React.FC<{ visible?: boolean }> = ({ visible = true }) => { + if (!visible) return null + + return ( +
+ ๐Ÿงช TEST MODE - Payment system is running in test mode for development +
+ ) +} + +export const TestModeBadge: React.FC<{ visible?: boolean }> = ({ visible = true }) => { + if (!visible) return null + + return ( + + Test + + ) +} + +export const TestPaymentControls: React.FC<{ + paymentId?: string + onScenarioSelect?: (scenario: string) => void + onMethodSelect?: (method: string) => void +}> = ({ paymentId, onScenarioSelect, onMethodSelect }) => { + const [selectedScenario, setSelectedScenario] = React.useState('') + const [selectedMethod, setSelectedMethod] = React.useState('') + + const scenarios = [ + { id: 'instant-success', name: 'Instant Success', description: 'Payment succeeds immediately' }, + { id: 'delayed-success', name: 'Delayed Success', description: 'Payment succeeds after delay' }, + { id: 'cancelled-payment', name: 'Cancelled Payment', description: 'User cancels payment' }, + { id: 'declined-payment', name: 'Declined Payment', description: 'Payment declined' }, + { id: 'expired-payment', name: 'Expired Payment', description: 'Payment expires' }, + { id: 'pending-payment', name: 'Pending Payment', description: 'Payment stays pending' } + ] + + const methods = [ + { id: 'ideal', name: 'iDEAL', icon: '๐Ÿฆ' }, + { id: 'creditcard', name: 'Credit Card', icon: '๐Ÿ’ณ' }, + { id: 'paypal', name: 'PayPal', icon: '๐Ÿ…ฟ๏ธ' }, + { id: 'applepay', name: 'Apple Pay', icon: '๐ŸŽ' }, + { id: 'banktransfer', name: 'Bank Transfer', icon: '๐Ÿ›๏ธ' } + ] + + return ( +
+

๐Ÿงช Test Payment Controls

+ +
+ + +
+ +
+ + +
+ + {paymentId && ( +
+ + Payment ID: {paymentId} + +
+ )} +
+ ) +} + export default { BillingDashboardWidget, formatCurrency, getPaymentStatusColor, PaymentStatusBadge, + TestModeWarningBanner, + TestModeBadge, + TestPaymentControls, } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a8f0f96..5ecf30c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,17 @@ export { billingPlugin } from './plugin/index.js' export { mollieProvider, stripeProvider } from './providers/index.js' -export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config.js' +export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderConfig } from './plugin/config.js' export type { Invoice, Payment, Refund } from './plugin/types/index.js' export type { PaymentProvider, ProviderData } from './providers/types.js' -export type { MollieProviderConfig } from './providers/mollie.js' -export type { StripeProviderConfig } from './providers/stripe.js' + +// Export all providers +export { testProvider } from './providers/test.js' +export type { + StripeProviderConfig, + MollieProviderConfig, + TestProviderConfig, + PaymentOutcome, + PaymentMethod, + PaymentScenario +} from './providers/index.js' diff --git a/src/plugin/config.ts b/src/plugin/config.ts index c7dab3d..7b16838 100644 --- a/src/plugin/config.ts +++ b/src/plugin/config.ts @@ -19,6 +19,26 @@ export interface TestProviderConfig { simulateFailures?: boolean } +export interface AdvancedTestProviderConfig { + enabled: boolean + scenarios?: Array<{ + id: string + name: string + description: string + outcome: 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending' + delay?: number + method?: 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer' + }> + customUiRoute?: string + testModeIndicators?: { + showWarningBanners?: boolean + showTestBadges?: boolean + consoleWarnings?: boolean + } + defaultDelay?: number + baseUrl?: string +} + // Customer info extractor callback type export interface CustomerInfoExtractor { (customer: any): { diff --git a/src/providers/index.ts b/src/providers/index.ts index 738563f..b12969a 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,4 +1,10 @@ export * from './mollie.js' export * from './stripe.js' +export * from './test.js' export * from './types.js' export * from './currency.js' + +// Re-export provider configurations and types +export type { StripeProviderConfig } from './stripe.js' +export type { MollieProviderConfig } from './mollie.js' +export type { TestProviderConfig, PaymentOutcome, PaymentMethod, PaymentScenario } from './test.js' diff --git a/src/providers/test.ts b/src/providers/test.ts new file mode 100644 index 0000000..9851742 --- /dev/null +++ b/src/providers/test.ts @@ -0,0 +1,719 @@ +import type { Payment } from '../plugin/types/payments.js' +import type { PaymentProvider, ProviderData } from '../plugin/types/index.js' +import type { Payload } from 'payload' +import { webhookResponses, handleWebhookError, logWebhookEvent } from './utils.js' +import { isValidAmount, isValidCurrencyCode } from './currency.js' + +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 +} + +// 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 + scenario?: PaymentScenario + method?: PaymentMethod + createdAt: Date + status: PaymentOutcome +}>() + +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/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(async () => { + await processTestPayment(payload, session, pluginConfig) + }, 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: any, + pluginConfig: any +) { + 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 = pluginConfig.collections?.payments || 'payments' + 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: any, + 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 / 100).toFixed(2)}
+ ${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('')} +
+
+ + + + +
+
+ + + +` +} \ No newline at end of file