feat: implement advanced test provider with interactive UI and multiple scenarios

- Add comprehensive test provider with configurable payment outcomes (paid, failed, cancelled, expired, pending)
- Support multiple payment methods (iDEAL, Credit Card, PayPal, Apple Pay, Bank Transfer)
- Interactive test payment UI with responsive design and real-time processing simulation
- Test mode indicators including warning banners, badges, and console warnings
- React components for admin UI integration (TestModeWarningBanner, TestModeBadge, TestPaymentControls)
- API endpoints for test payment processing and status polling
- Configurable scenarios with custom delays and outcomes
- Production safety mechanisms and clear test mode indicators
- Complete documentation and usage examples

Implements GitHub issue #20 for advanced test provider functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-19 10:34:55 +02:00
parent 83251bb404
commit 8e6385caa3
6 changed files with 1025 additions and 3 deletions

View File

@@ -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
<TestModeWarningBanner visible={isTestMode} />
// Add test badge to payment status
<div>
Payment Status: {status}
<TestModeBadge visible={isTestMode} />
</div>
// Payment testing controls
<TestPaymentControls
paymentId={paymentId}
onScenarioSelect={(scenario) => 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!

View File

@@ -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 (
<div style={{
background: 'linear-gradient(90deg, #ff6b6b, #ffa726)',
color: 'white',
padding: '12px 20px',
textAlign: 'center',
fontWeight: 600,
fontSize: '14px',
marginBottom: '20px',
borderRadius: '4px'
}}>
🧪 TEST MODE - Payment system is running in test mode for development
</div>
)
}
export const TestModeBadge: React.FC<{ visible?: boolean }> = ({ visible = true }) => {
if (!visible) return null
return (
<span style={{
display: 'inline-block',
background: '#6c757d',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase',
marginLeft: '8px'
}}>
Test
</span>
)
}
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 (
<div style={{ border: '1px solid #e9ecef', borderRadius: '8px', padding: '16px', margin: '16px 0' }}>
<h4 style={{ marginBottom: '12px', color: '#2c3e50' }}>🧪 Test Payment Controls</h4>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Payment Method:</label>
<select
value={selectedMethod}
onChange={(e) => {
setSelectedMethod(e.target.value)
onMethodSelect?.(e.target.value)
}}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
>
<option value="">Select payment method...</option>
{methods.map(method => (
<option key={method.id} value={method.id}>
{method.icon} {method.name}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600' }}>Test Scenario:</label>
<select
value={selectedScenario}
onChange={(e) => {
setSelectedScenario(e.target.value)
onScenarioSelect?.(e.target.value)
}}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
>
<option value="">Select test scenario...</option>
{scenarios.map(scenario => (
<option key={scenario.id} value={scenario.id}>
{scenario.name} - {scenario.description}
</option>
))}
</select>
</div>
{paymentId && (
<div style={{ marginTop: '12px', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<small style={{ color: '#6c757d' }}>
Payment ID: <code>{paymentId}</code>
</small>
</div>
)}
</div>
)
}
export default {
BillingDashboardWidget,
formatCurrency,
getPaymentStatusColor,
PaymentStatusBadge,
TestModeWarningBanner,
TestModeBadge,
TestPaymentControls,
}

View File

@@ -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'

View File

@@ -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): {

View File

@@ -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'

719
src/providers/test.ts Normal file
View File

@@ -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<PaymentMethod, { name: string; icon: string }> = {
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<string, {
id: string
payment: Partial<Payment>
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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Payment - ${payment.description || 'Payment'}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
${testModeIndicators.showWarningBanners !== false ? `
.test-banner {
background: linear-gradient(90deg, #ff6b6b, #ffa726);
color: white;
padding: 12px 20px;
text-align: center;
font-weight: 600;
font-size: 14px;
}
` : ''}
.header {
background: #f8f9fa;
padding: 30px 40px 20px;
border-bottom: 1px solid #e9ecef;
}
.title {
font-size: 24px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 8px;
}
.amount {
font-size: 32px;
font-weight: 800;
color: #27ae60;
margin-bottom: 16px;
}
.description {
color: #6c757d;
font-size: 16px;
line-height: 1.5;
}
.content { padding: 40px; }
.section { margin-bottom: 30px; }
.section-title {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.payment-methods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.method {
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 16px 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.method:hover {
border-color: #007bff;
background: #f8f9ff;
}
.method.selected {
border-color: #007bff;
background: #007bff;
color: white;
}
.method-icon { font-size: 24px; margin-bottom: 8px; }
.method-name { font-size: 12px; font-weight: 500; }
.scenarios {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
.scenario {
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.scenario:hover {
border-color: #28a745;
background: #f8fff9;
}
.scenario.selected {
border-color: #28a745;
background: #28a745;
color: white;
}
.scenario-name { font-weight: 600; margin-bottom: 4px; }
.scenario-desc { font-size: 14px; opacity: 0.8; }
.process-btn {
width: 100%;
background: linear-gradient(135deg, #007bff, #0056b3);
color: white;
border: none;
padding: 16px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 20px;
}
.process-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,123,255,0.3);
}
.process-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.status {
text-align: center;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
font-weight: 600;
}
.status.processing { background: #fff3cd; color: #856404; }
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
${testModeIndicators.showTestBadges !== false ? `
.test-badge {
display: inline-block;
background: #6c757d;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
margin-left: 8px;
}
` : ''}
</style>
</head>
<body>
<div class="container">
${testModeIndicators.showWarningBanners !== false ? `
<div class="test-banner">
🧪 TEST MODE - This is a simulated payment for development purposes
</div>
` : ''}
<div class="header">
<div class="title">
Test Payment Checkout
${testModeIndicators.showTestBadges !== false ? '<span class="test-badge">Test</span>' : ''}
</div>
<div class="amount">${payment.currency?.toUpperCase()} ${(payment.amount / 100).toFixed(2)}</div>
${payment.description ? `<div class="description">${payment.description}</div>` : ''}
</div>
<div class="content">
<div class="section">
<div class="section-title">
💳 Select Payment Method
</div>
<div class="payment-methods">
${Object.entries(PAYMENT_METHODS).map(([key, method]) => `
<div class="method" data-method="${key}">
<div class="method-icon">${method.icon}</div>
<div class="method-name">${method.name}</div>
</div>
`).join('')}
</div>
</div>
<div class="section">
<div class="section-title">
🎭 Select Test Scenario
</div>
<div class="scenarios">
${scenarios.map(scenario => `
<div class="scenario" data-scenario="${scenario.id}">
<div class="scenario-name">${scenario.name}</div>
<div class="scenario-desc">${scenario.description}</div>
</div>
`).join('')}
</div>
</div>
<button class="process-btn" id="processBtn" disabled>
Process Test Payment
</button>
<div id="status" class="status" style="display: none;"></div>
</div>
</div>
<script>
let selectedMethod = null;
let selectedScenario = null;
// Payment method selection
document.querySelectorAll('.method').forEach(method => {
method.addEventListener('click', () => {
document.querySelectorAll('.method').forEach(m => m.classList.remove('selected'));
method.classList.add('selected');
selectedMethod = method.dataset.method;
updateProcessButton();
});
});
// Scenario selection
document.querySelectorAll('.scenario').forEach(scenario => {
scenario.addEventListener('click', () => {
document.querySelectorAll('.scenario').forEach(s => s.classList.remove('selected'));
scenario.classList.add('selected');
selectedScenario = scenario.dataset.scenario;
updateProcessButton();
});
});
function updateProcessButton() {
const btn = document.getElementById('processBtn');
btn.disabled = !selectedMethod || !selectedScenario;
}
// Process payment
document.getElementById('processBtn').addEventListener('click', async () => {
const btn = document.getElementById('processBtn');
const status = document.getElementById('status');
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>Processing...';
try {
const response = await fetch('/api/payload-billing/test/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentId: '${session.id}',
scenarioId: selectedScenario,
method: selectedMethod
})
});
const result = await response.json();
if (result.success) {
status.className = 'status processing';
status.style.display = 'block';
status.innerHTML = \`<span class="loading"></span>Processing payment with \${result.scenario}...\`;
// Poll for status updates
setTimeout(() => pollStatus(), result.delay || 1000);
} else {
throw new Error(result.error || 'Failed to process payment');
}
} catch (error) {
status.className = 'status error';
status.style.display = 'block';
status.textContent = 'Error: ' + error.message;
btn.disabled = false;
btn.textContent = 'Process Test Payment';
}
});
async function pollStatus() {
try {
const response = await fetch('/api/payload-billing/test/status/${session.id}');
const result = await response.json();
const status = document.getElementById('status');
const btn = document.getElementById('processBtn');
if (result.status === 'paid') {
status.className = 'status success';
status.textContent = '✅ Payment successful!';
setTimeout(() => {
window.location.href = '${baseUrl}/success';
}, 2000);
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
status.className = 'status error';
status.textContent = \`❌ Payment \${result.status}\`;
btn.disabled = false;
btn.textContent = 'Try Again';
} else if (result.status === 'pending') {
status.className = 'status processing';
status.innerHTML = '<span class="loading"></span>Payment is still pending...';
setTimeout(() => pollStatus(), 2000);
}
} catch (error) {
console.error('Failed to poll status:', error);
}
}
${testModeIndicators.consoleWarnings !== false ? `
console.warn('🧪 TEST MODE: This is a simulated payment interface for development purposes');
` : ''}
</script>
</body>
</html>`
}