Files
payload-billing/src/providers/test.ts
Bas van den Aakster 79166f7edf fix: respect customUiRoute configuration in test provider
The test provider's customUiRoute parameter was being ignored when
generating checkout URLs. The checkout URL was always using the
hardcoded API endpoint instead of the configured custom UI route.

This fix ensures that when customUiRoute is configured, the generated
checkoutUrl will use the custom route (e.g., /test-payment/:id)
instead of the default API route.

Fixes issue where test provider checkout URLs returned 404 errors
because they pointed to /api/payload-billing/test/payment/:id instead
of the configured custom UI route.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:17:38 +01:00

944 lines
32 KiB
TypeScript

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.for('@xtr-dev/payload-billing/test-mode-warning')
const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis
const setTestModeWarning = () => ((<any>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<Payment>
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<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, TestPaymentSession>()
export const testProvider = (testConfig: TestProviderConfig) => {
if (!testConfig.enabled) {
return
}
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: (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: () => {
const response: TestProviderConfigResponse = {
enabled: testConfig.enabled,
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: (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 paymentUrl = `${baseUrl}${uiRoute}/${testPaymentId}`
const providerData: ProviderData = {
raw: {
id: testPaymentId,
amount: payment.amount,
currency: payment.currency,
description: payment.description,
status: 'pending',
testMode: true,
paymentUrl,
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
payment.checkoutUrl = paymentUrl
return payment
},
} satisfies PaymentProvider
}
// Helper function to process test payment based on scenario
async function processTestPayment(
payload: Payload,
session: TestPaymentSession,
pluginConfig: BillingPluginConfig
): Promise<void> {
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 `<!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 ? (payment.amount / 100).toFixed(2) : '0.00'}</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('[Test Provider] Failed to poll status:', error);
}
}
${testModeIndicators.consoleWarnings !== false ? `
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface for development purposes');
` : ''}
</script>
</body>
</html>`
}