diff --git a/README.md b/README.md
index 3f49c53..2c679dd 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# @xtr-dev/payload-billing
+[](https://badge.fury.io/js/@xtr-dev%2Fpayload-billing)
+
A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Mollie, and local testing with comprehensive tracking and flexible customer data management.
โ ๏ธ **Pre-release Warning**: This package is currently in active development (v0.1.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
diff --git a/dev/payload.config.ts b/dev/payload.config.ts
index f7db56d..e746f8a 100644
--- a/dev/payload.config.ts
+++ b/dev/payload.config.ts
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter'
import { seed } from './seed'
import billingPlugin from '../src/plugin'
-import { mollieProvider } from '../src/providers'
+import { testProvider } from '../src/providers'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -50,8 +50,13 @@ const buildConfigWithSQLite = () => {
plugins: [
billingPlugin({
providers: [
- mollieProvider({
- apiKey: process.env.MOLLIE_KEY!
+ testProvider({
+ enabled: true,
+ testModeIndicators: {
+ showWarningBanners: true,
+ showTestBadges: true,
+ consoleWarnings: true
+ }
})
],
collections: {
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/package.json b/package.json
index 06c6c82..d2b981b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-billing",
- "version": "0.1.7",
+ "version": "0.1.8",
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT",
"type": "module",
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..f3b914f
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/collections/hooks.ts b/src/collections/hooks.ts
index 7a0a289..0e62ef8 100644
--- a/src/collections/hooks.ts
+++ b/src/collections/hooks.ts
@@ -1,6 +1,6 @@
-import type { Payment } from '../plugin/types/index.js'
+import type { Payment } from '../plugin/types/index'
import type { Payload } from 'payload'
-import { useBillingPlugin } from '../plugin/index.js'
+import { useBillingPlugin } from '../plugin/index'
export const initProviderPayment = (payload: Payload, payment: Partial) => {
const billing = useBillingPlugin(payload)
diff --git a/src/collections/index.ts b/src/collections/index.ts
index 72abd52..c0f2f5a 100644
--- a/src/collections/index.ts
+++ b/src/collections/index.ts
@@ -1,3 +1,3 @@
-export { createInvoicesCollection } from './invoices.js'
-export { createPaymentsCollection } from './payments.js'
-export { createRefundsCollection } from './refunds.js'
+export { createInvoicesCollection } from './invoices'
+export { createPaymentsCollection } from './payments'
+export { createRefundsCollection } from './refunds'
diff --git a/src/collections/invoices.ts b/src/collections/invoices.ts
index 77a3978..6731797 100644
--- a/src/collections/invoices.ts
+++ b/src/collections/invoices.ts
@@ -5,10 +5,10 @@ import {
CollectionBeforeValidateHook,
CollectionConfig, Field,
} from 'payload'
-import type { BillingPluginConfig} from '../plugin/config.js';
-import { defaults } from '../plugin/config.js'
-import { extractSlug } from '../plugin/utils.js'
-import type { Invoice } from '../plugin/types/invoices.js'
+import type { BillingPluginConfig} from '../plugin/config';
+import { defaults } from '../plugin/config'
+import { extractSlug } from '../plugin/utils'
+import type { Invoice } from '../plugin/types/invoices'
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
diff --git a/src/collections/payments.ts b/src/collections/payments.ts
index 68978f7..c6263e5 100644
--- a/src/collections/payments.ts
+++ b/src/collections/payments.ts
@@ -1,9 +1,9 @@
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
-import type { BillingPluginConfig} from '../plugin/config.js';
-import { defaults } from '../plugin/config.js'
-import { extractSlug } from '../plugin/utils.js'
-import type { Payment } from '../plugin/types/payments.js'
-import { initProviderPayment } from './hooks.js'
+import type { BillingPluginConfig} from '../plugin/config';
+import { defaults } from '../plugin/config'
+import { extractSlug } from '../plugin/utils'
+import type { Payment } from '../plugin/types/payments'
+import { initProviderPayment } from './hooks'
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
diff --git a/src/collections/refunds.ts b/src/collections/refunds.ts
index bca2f24..66fc6d8 100644
--- a/src/collections/refunds.ts
+++ b/src/collections/refunds.ts
@@ -1,7 +1,7 @@
import type { AccessArgs, CollectionConfig } from 'payload'
-import { BillingPluginConfig, defaults } from '../plugin/config.js'
-import { extractSlug } from '../plugin/utils.js'
-import { Payment } from '../plugin/types/index.js'
+import { BillingPluginConfig, defaults } from '../plugin/config'
+import { extractSlug } from '../plugin/utils'
+import { Payment } from '../plugin/types/index'
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
// TODO: finish collection overrides
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..1e7f8c7 100644
--- a/src/plugin/config.ts
+++ b/src/plugin/config.ts
@@ -1,6 +1,6 @@
import { CollectionConfig } from 'payload'
-import { FieldsOverride } from './utils.js'
-import { PaymentProvider } from './types/index.js'
+import { FieldsOverride } from './utils'
+import { PaymentProvider } from './types/index'
export const defaults = {
paymentsCollection: 'payments',
@@ -19,6 +19,9 @@ export interface TestProviderConfig {
simulateFailures?: boolean
}
+// Re-export the actual test provider config instead of duplicating
+export type { TestProviderConfig as AdvancedTestProviderConfig } from '../providers/test'
+
// Customer info extractor callback type
export interface CustomerInfoExtractor {
(customer: any): {
diff --git a/src/plugin/index.ts b/src/plugin/index.ts
index a8d5d97..7e837fe 100644
--- a/src/plugin/index.ts
+++ b/src/plugin/index.ts
@@ -1,8 +1,8 @@
-import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index.js'
-import type { BillingPluginConfig } from './config.js'
+import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index'
+import type { BillingPluginConfig } from './config'
import type { Config, Payload } from 'payload'
-import { createSingleton } from './singleton.js'
-import type { PaymentProvider } from '../providers/index.js'
+import { createSingleton } from './singleton'
+import type { PaymentProvider } from '../providers/index'
const singleton = createSingleton(Symbol('billingPlugin'))
diff --git a/src/plugin/types/invoices.ts b/src/plugin/types/invoices.ts
index fdc1719..8249b7a 100644
--- a/src/plugin/types/invoices.ts
+++ b/src/plugin/types/invoices.ts
@@ -1,5 +1,5 @@
-import { Payment } from './payments.js'
-import { Id } from './id.js'
+import { Payment } from './payments'
+import { Id } from './id'
export interface Invoice {
id: Id;
diff --git a/src/plugin/types/payments.ts b/src/plugin/types/payments.ts
index 1d8b79c..5e803a9 100644
--- a/src/plugin/types/payments.ts
+++ b/src/plugin/types/payments.ts
@@ -1,6 +1,6 @@
-import { Refund } from './refunds.js'
-import { Invoice } from './invoices.js'
-import { Id } from './id.js'
+import { Refund } from './refunds'
+import { Invoice } from './invoices'
+import { Id } from './id'
export interface Payment {
id: Id;
diff --git a/src/plugin/types/refunds.ts b/src/plugin/types/refunds.ts
index 1cab98b..df05c51 100644
--- a/src/plugin/types/refunds.ts
+++ b/src/plugin/types/refunds.ts
@@ -1,4 +1,4 @@
-import { Payment } from './payments.js'
+import { Payment } from './payments'
export interface Refund {
id: number;
diff --git a/src/plugin/utils.ts b/src/plugin/utils.ts
index b9a7dae..9d81725 100644
--- a/src/plugin/utils.ts
+++ b/src/plugin/utils.ts
@@ -1,5 +1,5 @@
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
-import type { Id } from './types/index.js'
+import type { Id } from './types/index'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 738563f..c593f82 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 './types.js'
-export * from './currency.js'
+export * from './mollie'
+export * from './stripe'
+export * from './test'
+export * from './types'
+export * from './currency'
+
+// Re-export provider configurations and types
+export type { StripeProviderConfig } from './stripe'
+export type { MollieProviderConfig } from './mollie'
+export type { TestProviderConfig, PaymentOutcome, PaymentMethod, PaymentScenario } from './test'
diff --git a/src/providers/mollie.ts b/src/providers/mollie.ts
index af8918c..208a6f1 100644
--- a/src/providers/mollie.ts
+++ b/src/providers/mollie.ts
@@ -1,7 +1,7 @@
-import type { Payment } from '../plugin/types/payments.js'
-import type { PaymentProvider } from '../plugin/types/index.js'
+import type { Payment } from '../plugin/types/payments'
+import type { PaymentProvider } from '../plugin/types/index'
import type { Payload } from 'payload'
-import { createSingleton } from '../plugin/singleton.js'
+import { createSingleton } from '../plugin/singleton'
import type { createMollieClient, MollieClient } from '@mollie/api-client'
import {
webhookResponses,
@@ -10,8 +10,8 @@ import {
updateInvoiceOnPaymentSuccess,
handleWebhookError,
validateProductionUrl
-} from './utils.js'
-import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency.js'
+} from './utils'
+import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
const symbol = Symbol('mollie')
export type MollieProviderConfig = Parameters[0]
diff --git a/src/providers/stripe.ts b/src/providers/stripe.ts
index fc64f94..28dca74 100644
--- a/src/providers/stripe.ts
+++ b/src/providers/stripe.ts
@@ -1,7 +1,7 @@
-import type { Payment } from '../plugin/types/payments.js'
-import type { PaymentProvider, ProviderData } from '../plugin/types/index.js'
+import type { Payment } from '../plugin/types/payments'
+import type { PaymentProvider, ProviderData } from '../plugin/types/index'
import type { Payload } from 'payload'
-import { createSingleton } from '../plugin/singleton.js'
+import { createSingleton } from '../plugin/singleton'
import type Stripe from 'stripe'
import {
webhookResponses,
@@ -10,8 +10,8 @@ import {
updateInvoiceOnPaymentSuccess,
handleWebhookError,
logWebhookEvent
-} from './utils.js'
-import { isValidAmount, isValidCurrencyCode } from './currency.js'
+} from './utils'
+import { isValidAmount, isValidCurrencyCode } from './currency'
const symbol = Symbol('stripe')
diff --git a/src/providers/test.ts b/src/providers/test.ts
new file mode 100644
index 0000000..09236a5
--- /dev/null
+++ b/src/providers/test.ts
@@ -0,0 +1,759 @@
+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
+}
+
+// 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/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
+
+ ` : ''}
+
+
+
+
+
+
+ ๐ณ 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('')}
+
+
+
+
+
+
+
+
+
+
+
+`
+}
diff --git a/src/providers/types.ts b/src/providers/types.ts
index 0950cf6..311e9ad 100644
--- a/src/providers/types.ts
+++ b/src/providers/types.ts
@@ -1,6 +1,6 @@
-import type { Payment } from '../plugin/types/payments.js'
+import type { Payment } from '../plugin/types/payments'
import type { Config, Payload } from 'payload'
-import type { BillingPluginConfig } from '../plugin/config.js'
+import type { BillingPluginConfig } from '../plugin/config'
export type InitPayment = (payload: Payload, payment: Partial) => Promise>
diff --git a/src/providers/utils.ts b/src/providers/utils.ts
index 728d7c8..4241db5 100644
--- a/src/providers/utils.ts
+++ b/src/providers/utils.ts
@@ -1,9 +1,9 @@
import type { Payload } from 'payload'
-import type { Payment } from '../plugin/types/payments.js'
-import type { BillingPluginConfig } from '../plugin/config.js'
-import type { ProviderData } from './types.js'
-import { defaults } from '../plugin/config.js'
-import { extractSlug, toPayloadId } from '../plugin/utils.js'
+import type { Payment } from '../plugin/types/payments'
+import type { BillingPluginConfig } from '../plugin/config'
+import type { ProviderData } from './types'
+import { defaults } from '../plugin/config'
+import { extractSlug, toPayloadId } from '../plugin/utils'
/**
* Common webhook response utilities