mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
Compare commits
26 Commits
v0.1.5
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf54ae893 | ||
|
|
f89ffb2c7e | ||
| d5a47a05b1 | |||
| 64c58552cb | |||
| be57924525 | |||
| 2d10bd82e7 | |||
| 8e6385caa3 | |||
| 83251bb404 | |||
|
|
7b8c89a0a2 | ||
| d651e8199c | |||
| f77719716f | |||
|
|
c6e51892e6 | ||
|
|
38c8c3677d | ||
|
|
e74a2410e6 | ||
|
|
27b86132e9 | ||
| ec635fb707 | |||
| cabe6eda96 | |||
| a3108a0f49 | |||
|
|
113a0d36c0 | ||
| 8ac328e14f | |||
| 7a3d6ec26e | |||
| 534b0e440f | |||
|
|
669a9decd5 | ||
| bfa214aed6 | |||
| c083ae183c | |||
| d09fe3054a |
7
.github/workflows/claude-code-review.yml
vendored
7
.github/workflows/claude-code-review.yml
vendored
@@ -12,11 +12,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude-review:
|
claude-review:
|
||||||
# Optional: Filter by PR author
|
# Only allow bvdaakster to trigger reviews
|
||||||
# if: |
|
if: github.event.pull_request.user.login == 'bvdaakster'
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
98
README.md
98
README.md
@@ -1,17 +1,21 @@
|
|||||||
# @xtr-dev/payload-billing
|
# @xtr-dev/payload-billing
|
||||||
|
|
||||||
A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Mollie, and local testing with comprehensive tracking.
|
[](https://badge.fury.io/js/@xtr-dev%2Fpayload-billing)
|
||||||
|
|
||||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
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.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 💳 Multiple payment providers (Stripe, Mollie, Test)
|
- 💳 Multiple payment providers (Stripe, Mollie, Test)
|
||||||
- 🧾 Invoice generation and management
|
- 🧾 Invoice generation and management with embedded customer info
|
||||||
|
- 👥 Flexible customer data management with relationship support
|
||||||
- 📊 Complete payment tracking and history
|
- 📊 Complete payment tracking and history
|
||||||
- 🪝 Secure webhook processing for all providers
|
- 🪝 Secure webhook processing for all providers
|
||||||
- 🧪 Built-in test provider for local development
|
- 🧪 Built-in test provider for local development
|
||||||
- 📱 Payment management in PayloadCMS admin
|
- 📱 Payment management in PayloadCMS admin
|
||||||
|
- 🔄 Callback-based customer data syncing
|
||||||
- 🔒 Full TypeScript support
|
- 🔒 Full TypeScript support
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -42,6 +46,8 @@ pnpm add @mollie/api-client
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Configuration
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||||
@@ -70,6 +76,68 @@ export default buildConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### With Customer Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { billingPlugin, CustomerInfoExtractor } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
|
// Define how to extract customer info from your customer collection
|
||||||
|
const customerExtractor: CustomerInfoExtractor = (customer) => ({
|
||||||
|
name: customer.name,
|
||||||
|
email: customer.email,
|
||||||
|
phone: customer.phone,
|
||||||
|
company: customer.company,
|
||||||
|
taxId: customer.taxId,
|
||||||
|
billingAddress: {
|
||||||
|
line1: customer.address.line1,
|
||||||
|
line2: customer.address.line2,
|
||||||
|
city: customer.address.city,
|
||||||
|
state: customer.address.state,
|
||||||
|
postalCode: customer.address.postalCode,
|
||||||
|
country: customer.address.country,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
billingPlugin({
|
||||||
|
// ... providers
|
||||||
|
collections: {
|
||||||
|
payments: 'payments',
|
||||||
|
invoices: 'invoices',
|
||||||
|
refunds: 'refunds',
|
||||||
|
},
|
||||||
|
customerRelationSlug: 'customers', // Enable customer relationships
|
||||||
|
customerInfoExtractor: customerExtractor, // Auto-sync customer data
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Customer Data Extraction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CustomerInfoExtractor } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
|
const customExtractor: CustomerInfoExtractor = (customer) => ({
|
||||||
|
name: customer.fullName,
|
||||||
|
email: customer.contactEmail,
|
||||||
|
phone: customer.phoneNumber,
|
||||||
|
company: customer.companyName,
|
||||||
|
taxId: customer.vatNumber,
|
||||||
|
billingAddress: {
|
||||||
|
line1: customer.billing.street,
|
||||||
|
line2: customer.billing.apartment,
|
||||||
|
city: customer.billing.city,
|
||||||
|
state: customer.billing.state,
|
||||||
|
postalCode: customer.billing.zip,
|
||||||
|
country: customer.billing.countryCode,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
billingPlugin({
|
||||||
|
// ... other config
|
||||||
|
customerRelationSlug: 'clients',
|
||||||
|
customerInfoExtractor: customExtractor,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
## Imports
|
## Imports
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -80,7 +148,17 @@ import { billingPlugin } from '@xtr-dev/payload-billing'
|
|||||||
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing'
|
import type {
|
||||||
|
PaymentProvider,
|
||||||
|
Payment,
|
||||||
|
Invoice,
|
||||||
|
Refund,
|
||||||
|
BillingPluginConfig,
|
||||||
|
CustomerInfoExtractor,
|
||||||
|
MollieProviderConfig,
|
||||||
|
StripeProviderConfig,
|
||||||
|
ProviderData
|
||||||
|
} from '@xtr-dev/payload-billing'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Provider Types
|
## Provider Types
|
||||||
@@ -99,9 +177,19 @@ Local development testing with configurable scenarios, automatic completion, deb
|
|||||||
The plugin adds these collections:
|
The plugin adds these collections:
|
||||||
|
|
||||||
- **payments** - Payment transactions with status and provider data
|
- **payments** - Payment transactions with status and provider data
|
||||||
- **invoices** - Invoice generation with line items and PDF support
|
- **invoices** - Invoice generation with line items and embedded customer info
|
||||||
- **refunds** - Refund tracking and management
|
- **refunds** - Refund tracking and management
|
||||||
|
|
||||||
|
### Customer Data Management
|
||||||
|
|
||||||
|
The plugin supports flexible customer data handling:
|
||||||
|
|
||||||
|
1. **With Customer Relationship + Extractor**: Customer relationship required, customer info auto-populated and read-only, syncs automatically when customer changes
|
||||||
|
|
||||||
|
2. **With Customer Relationship (no extractor)**: Customer relationship optional, customer info manually editable, either relationship OR customer info required
|
||||||
|
|
||||||
|
3. **No Customer Collection**: Customer info fields always required and editable, no relationship field available
|
||||||
|
|
||||||
## Webhook Endpoints
|
## Webhook Endpoints
|
||||||
|
|
||||||
Automatic webhook endpoints are created for configured providers:
|
Automatic webhook endpoints are created for configured providers:
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export interface Payment {
|
|||||||
/**
|
/**
|
||||||
* The payment ID from the payment provider
|
* The payment ID from the payment provider
|
||||||
*/
|
*/
|
||||||
providerId: string;
|
providerId?: string | null;
|
||||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
|
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded';
|
||||||
/**
|
/**
|
||||||
* Amount in cents (e.g., 2000 = $20.00)
|
* Amount in cents (e.g., 2000 = $20.00)
|
||||||
@@ -198,6 +198,7 @@ export interface Payment {
|
|||||||
| boolean
|
| boolean
|
||||||
| null;
|
| null;
|
||||||
refunds?: (number | Refund)[] | null;
|
refunds?: (number | Refund)[] | null;
|
||||||
|
version?: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -499,6 +500,7 @@ export interface PaymentsSelect<T extends boolean = true> {
|
|||||||
metadata?: T;
|
metadata?: T;
|
||||||
providerData?: T;
|
providerData?: T;
|
||||||
refunds?: T;
|
refunds?: T;
|
||||||
|
version?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'url'
|
|||||||
import { testEmailAdapter } from './helpers/testEmailAdapter'
|
import { testEmailAdapter } from './helpers/testEmailAdapter'
|
||||||
import { seed } from './seed'
|
import { seed } from './seed'
|
||||||
import billingPlugin from '../src/plugin'
|
import billingPlugin from '../src/plugin'
|
||||||
import { mollieProvider } from '../src/providers'
|
import { testProvider } from '../src/providers'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -50,8 +50,13 @@ const buildConfigWithSQLite = () => {
|
|||||||
plugins: [
|
plugins: [
|
||||||
billingPlugin({
|
billingPlugin({
|
||||||
providers: [
|
providers: [
|
||||||
mollieProvider({
|
testProvider({
|
||||||
apiKey: process.env.MOLLIE_KEY!
|
enabled: true,
|
||||||
|
testModeIndicators: {
|
||||||
|
showWarningBanners: true,
|
||||||
|
showTestBadges: true,
|
||||||
|
consoleWarnings: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
collections: {
|
collections: {
|
||||||
|
|||||||
147
docs/test-provider-example.md
Normal file
147
docs/test-provider-example.md
Normal 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!
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-billing",
|
"name": "@xtr-dev/payload-billing",
|
||||||
"version": "0.1.5",
|
"version": "0.1.8",
|
||||||
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
76
playwright-report/index.html
Normal file
76
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -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 type { Payload } from 'payload'
|
||||||
import { useBillingPlugin } from '../plugin/index.js'
|
import { useBillingPlugin } from '../plugin/index'
|
||||||
|
|
||||||
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => {
|
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => {
|
||||||
const billing = useBillingPlugin(payload)
|
const billing = useBillingPlugin(payload)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { createInvoicesCollection } from './invoices.js'
|
export { createInvoicesCollection } from './invoices'
|
||||||
export { createPaymentsCollection } from './payments.js'
|
export { createPaymentsCollection } from './payments'
|
||||||
export { createRefundsCollection } from './refunds.js'
|
export { createRefundsCollection } from './refunds'
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
CollectionBeforeValidateHook,
|
CollectionBeforeValidateHook,
|
||||||
CollectionConfig, Field,
|
CollectionConfig, Field,
|
||||||
} from 'payload'
|
} from 'payload'
|
||||||
import type { BillingPluginConfig} from '../plugin/config.js';
|
import type { BillingPluginConfig} from '../plugin/config';
|
||||||
import { defaults } from '../plugin/config.js'
|
import { defaults } from '../plugin/config'
|
||||||
import { extractSlug } from '../plugin/utils.js'
|
import { extractSlug } from '../plugin/utils'
|
||||||
import type { Invoice } from '../plugin/types/invoices.js'
|
import type { Invoice } from '../plugin/types/invoices'
|
||||||
|
|
||||||
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
|
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
|
||||||
import type { BillingPluginConfig} from '../plugin/config.js';
|
import type { BillingPluginConfig} from '../plugin/config';
|
||||||
import { defaults } from '../plugin/config.js'
|
import { defaults } from '../plugin/config'
|
||||||
import { extractSlug } from '../plugin/utils.js'
|
import { extractSlug } from '../plugin/utils'
|
||||||
import type { Payment } from '../plugin/types/payments.js'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import { initProviderPayment } from './hooks.js'
|
import { initProviderPayment } from './hooks'
|
||||||
|
|
||||||
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
|
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||||
import { BillingPluginConfig, defaults } from '../plugin/config.js'
|
import { BillingPluginConfig, defaults } from '../plugin/config'
|
||||||
import { extractSlug } from '../plugin/utils.js'
|
import { extractSlug } from '../plugin/utils'
|
||||||
import { Payment } from '../plugin/types/index.js'
|
import { Payment } from '../plugin/types/index'
|
||||||
|
|
||||||
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
// TODO: finish collection overrides
|
// TODO: finish collection overrides
|
||||||
|
|||||||
@@ -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 {
|
export default {
|
||||||
BillingDashboardWidget,
|
BillingDashboardWidget,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
getPaymentStatusColor,
|
getPaymentStatusColor,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
|
TestModeWarningBanner,
|
||||||
|
TestModeBadge,
|
||||||
|
TestPaymentControls,
|
||||||
}
|
}
|
||||||
16
src/index.ts
16
src/index.ts
@@ -1,4 +1,18 @@
|
|||||||
|
|
||||||
export { billingPlugin } from './plugin/index.js'
|
export { billingPlugin } from './plugin/index.js'
|
||||||
export type { BillingPluginConfig, CustomerInfoExtractor } from './plugin/config.js'
|
export { mollieProvider, stripeProvider } from './providers/index.js'
|
||||||
|
export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderConfig } from './plugin/config.js'
|
||||||
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
|
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
|
||||||
|
export type { PaymentProvider, ProviderData } from './providers/types.js'
|
||||||
|
|
||||||
|
// Export all providers
|
||||||
|
export { testProvider } from './providers/test.js'
|
||||||
|
export type {
|
||||||
|
StripeProviderConfig,
|
||||||
|
MollieProviderConfig,
|
||||||
|
TestProviderConfig,
|
||||||
|
TestProviderConfigResponse,
|
||||||
|
PaymentOutcome,
|
||||||
|
PaymentMethod,
|
||||||
|
PaymentScenario
|
||||||
|
} from './providers/index.js'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
import { FieldsOverride } from './utils.js'
|
import { FieldsOverride } from './utils'
|
||||||
import { PaymentProvider } from './types/index.js'
|
import { PaymentProvider } from './types/index'
|
||||||
|
|
||||||
export const defaults = {
|
export const defaults = {
|
||||||
paymentsCollection: 'payments',
|
paymentsCollection: 'payments',
|
||||||
@@ -19,6 +19,9 @@ export interface TestProviderConfig {
|
|||||||
simulateFailures?: boolean
|
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
|
// Customer info extractor callback type
|
||||||
export interface CustomerInfoExtractor {
|
export interface CustomerInfoExtractor {
|
||||||
(customer: any): {
|
(customer: any): {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index.js'
|
import { createInvoicesCollection, createPaymentsCollection, createRefundsCollection } from '../collections/index'
|
||||||
import type { BillingPluginConfig } from './config.js'
|
import type { BillingPluginConfig } from './config'
|
||||||
import type { Config, Payload } from 'payload'
|
import type { Config, Payload } from 'payload'
|
||||||
import { createSingleton } from './singleton.js'
|
import { createSingleton } from './singleton'
|
||||||
import type { PaymentProvider } from '../providers/index.js'
|
import type { PaymentProvider } from '../providers/index'
|
||||||
|
|
||||||
const singleton = createSingleton(Symbol('billingPlugin'))
|
const singleton = createSingleton(Symbol('billingPlugin'))
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Payment } from './payments.js'
|
import { Payment } from './payments'
|
||||||
import { Id } from './id.js'
|
import { Id } from './id'
|
||||||
|
|
||||||
export interface Invoice<TCustomer = unknown> {
|
export interface Invoice<TCustomer = unknown> {
|
||||||
id: Id;
|
id: Id;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Refund } from './refunds.js'
|
import { Refund } from './refunds'
|
||||||
import { Invoice } from './invoices.js'
|
import { Invoice } from './invoices'
|
||||||
import { Id } from './id.js'
|
import { Id } from './id'
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: Id;
|
id: Id;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Payment } from './payments.js'
|
import { Payment } from './payments'
|
||||||
|
|
||||||
export interface Refund {
|
export interface Refund {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CollectionConfig, CollectionSlug, Field } from 'payload'
|
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[]
|
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export * from './mollie.js'
|
export * from './mollie'
|
||||||
export * from './stripe.js'
|
export * from './stripe'
|
||||||
export * from './types.js'
|
export * from './test'
|
||||||
export * from './currency.js'
|
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, TestProviderConfigResponse, PaymentOutcome, PaymentMethod, PaymentScenario } from './test'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Payment } from '../plugin/types/payments.js'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { PaymentProvider } from '../plugin/types/index.js'
|
import type { PaymentProvider } from '../plugin/types/index'
|
||||||
import type { Payload } from 'payload'
|
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 type { createMollieClient, MollieClient } from '@mollie/api-client'
|
||||||
import {
|
import {
|
||||||
webhookResponses,
|
webhookResponses,
|
||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
updateInvoiceOnPaymentSuccess,
|
updateInvoiceOnPaymentSuccess,
|
||||||
handleWebhookError,
|
handleWebhookError,
|
||||||
validateProductionUrl
|
validateProductionUrl
|
||||||
} from './utils.js'
|
} from './utils'
|
||||||
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency.js'
|
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
|
||||||
const symbol = Symbol('mollie')
|
const symbol = Symbol('mollie')
|
||||||
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Payment } from '../plugin/types/payments.js'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { PaymentProvider, ProviderData } from '../plugin/types/index.js'
|
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import { createSingleton } from '../plugin/singleton.js'
|
import { createSingleton } from '../plugin/singleton'
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from 'stripe'
|
||||||
import {
|
import {
|
||||||
webhookResponses,
|
webhookResponses,
|
||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
updateInvoiceOnPaymentSuccess,
|
updateInvoiceOnPaymentSuccess,
|
||||||
handleWebhookError,
|
handleWebhookError,
|
||||||
logWebhookEvent
|
logWebhookEvent
|
||||||
} from './utils.js'
|
} from './utils'
|
||||||
import { isValidAmount, isValidCurrencyCode } from './currency.js'
|
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
|
||||||
const symbol = Symbol('stripe')
|
const symbol = Symbol('stripe')
|
||||||
|
|
||||||
|
|||||||
801
src/providers/test.ts
Normal file
801
src/providers/test.ts
Normal file
@@ -0,0 +1,801 @@
|
|||||||
|
import type { Payment } from '../plugin/types/payments'
|
||||||
|
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||||
|
import type { BillingPluginConfig } from '../plugin/config'
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
import { handleWebhookError, logWebhookEvent } from './utils'
|
||||||
|
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
|
||||||
|
export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending'
|
||||||
|
|
||||||
|
export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer'
|
||||||
|
|
||||||
|
export interface PaymentScenario {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
outcome: PaymentOutcome
|
||||||
|
delay?: number // Delay in milliseconds before processing
|
||||||
|
method?: PaymentMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestProviderConfig {
|
||||||
|
enabled: boolean
|
||||||
|
scenarios?: PaymentScenario[]
|
||||||
|
customUiRoute?: string
|
||||||
|
testModeIndicators?: {
|
||||||
|
showWarningBanners?: boolean
|
||||||
|
showTestBadges?: boolean
|
||||||
|
consoleWarnings?: boolean
|
||||||
|
}
|
||||||
|
defaultDelay?: number
|
||||||
|
baseUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestProviderConfigResponse {
|
||||||
|
enabled: boolean
|
||||||
|
scenarios: PaymentScenario[]
|
||||||
|
methods: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
}>
|
||||||
|
testModeIndicators: {
|
||||||
|
showWarningBanners: boolean
|
||||||
|
showTestBadges: boolean
|
||||||
|
consoleWarnings: boolean
|
||||||
|
}
|
||||||
|
defaultDelay: number
|
||||||
|
customUiRoute: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Properly typed session interface
|
||||||
|
export interface TestPaymentSession {
|
||||||
|
id: string
|
||||||
|
payment: Partial<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) {
|
||||||
|
throw new Error('Test provider is disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
|
||||||
|
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
|
||||||
|
const uiRoute = testConfig.customUiRoute || '/test-payment'
|
||||||
|
|
||||||
|
// Log test mode warnings if enabled
|
||||||
|
if (testConfig.testModeIndicators?.consoleWarnings !== false) {
|
||||||
|
console.warn('🧪 [TEST PROVIDER] Payment system is running in test mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'test',
|
||||||
|
onConfig: (config, pluginConfig) => {
|
||||||
|
// Register test payment UI endpoint
|
||||||
|
config.endpoints = [
|
||||||
|
...(config.endpoints || []),
|
||||||
|
{
|
||||||
|
path: '/payload-billing/test/payment/:id',
|
||||||
|
method: 'get',
|
||||||
|
handler: async (req) => {
|
||||||
|
// Extract payment ID from URL path
|
||||||
|
const urlParts = req.url?.split('/') || []
|
||||||
|
const paymentId = urlParts[urlParts.length - 1]
|
||||||
|
if (!paymentId) {
|
||||||
|
return new Response('Payment ID required', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = testPaymentSessions.get(paymentId)
|
||||||
|
if (!session) {
|
||||||
|
return new Response('Payment session not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate test payment UI
|
||||||
|
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig)
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { 'Content-Type': 'text/html' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/payload-billing/test/config',
|
||||||
|
method: 'get',
|
||||||
|
handler: async (req) => {
|
||||||
|
const response: TestProviderConfigResponse = {
|
||||||
|
enabled: testConfig.enabled,
|
||||||
|
scenarios: scenarios,
|
||||||
|
methods: Object.entries(PAYMENT_METHODS).map(([id, method]) => ({
|
||||||
|
id,
|
||||||
|
name: method.name,
|
||||||
|
icon: method.icon
|
||||||
|
})),
|
||||||
|
testModeIndicators: testConfig.testModeIndicators || {
|
||||||
|
showWarningBanners: true,
|
||||||
|
showTestBadges: true,
|
||||||
|
consoleWarnings: true
|
||||||
|
},
|
||||||
|
defaultDelay: testConfig.defaultDelay || 1000,
|
||||||
|
customUiRoute: uiRoute
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(response), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/payload-billing/test/process',
|
||||||
|
method: 'post',
|
||||||
|
handler: async (req) => {
|
||||||
|
try {
|
||||||
|
const payload = req.payload
|
||||||
|
const body = await req.json?.() || {}
|
||||||
|
const { paymentId, scenarioId, method } = body as any
|
||||||
|
|
||||||
|
const session = testPaymentSessions.get(paymentId)
|
||||||
|
if (!session) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenario = scenarios.find(s => s.id === scenarioId)
|
||||||
|
if (!scenario) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Invalid scenario' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session with selected scenario and method
|
||||||
|
session.scenario = scenario
|
||||||
|
session.method = method
|
||||||
|
session.status = 'pending'
|
||||||
|
|
||||||
|
// Process payment after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
|
||||||
|
console.error('[Test Provider] Failed to process payment:', error)
|
||||||
|
session.status = 'failed'
|
||||||
|
|
||||||
|
// Also update the payment record in database
|
||||||
|
try {
|
||||||
|
const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string'
|
||||||
|
? pluginConfig.collections.payments
|
||||||
|
: 'payments') as any
|
||||||
|
const payments = await payload.find({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
where: { providerId: { equals: session.id } },
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if (payments.docs.length > 0) {
|
||||||
|
await payload.update({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
id: payments.docs[0].id,
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
providerData: {
|
||||||
|
raw: { error: error.message, processedAt: new Date().toISOString() },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: 'test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('[Test Provider] Failed to update payment in database:', dbError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, scenario.delay || testConfig.defaultDelay || 1000)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
status: 'processing',
|
||||||
|
scenario: scenario.name,
|
||||||
|
delay: scenario.delay || testConfig.defaultDelay || 1000
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleWebhookError('Test Provider', error, 'Failed to process test payment')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/payload-billing/test/status/:id',
|
||||||
|
method: 'get',
|
||||||
|
handler: async (req) => {
|
||||||
|
// Extract payment ID from URL path
|
||||||
|
const urlParts = req.url?.split('/') || []
|
||||||
|
const paymentId = urlParts[urlParts.length - 1]
|
||||||
|
if (!paymentId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Payment ID required' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = testPaymentSessions.get(paymentId)
|
||||||
|
if (!session) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
status: session.status,
|
||||||
|
scenario: session.scenario?.name,
|
||||||
|
method: session.method ? PAYMENT_METHODS[session.method]?.name : undefined
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
onInit: async (payload: Payload) => {
|
||||||
|
logWebhookEvent('Test Provider', 'Test payment provider initialized')
|
||||||
|
|
||||||
|
// Clean up old sessions periodically (older than 1 hour)
|
||||||
|
setInterval(() => {
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
||||||
|
testPaymentSessions.forEach((session, id) => {
|
||||||
|
if (session.createdAt < oneHourAgo) {
|
||||||
|
testPaymentSessions.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 10 * 60 * 1000) // Clean every 10 minutes
|
||||||
|
},
|
||||||
|
initPayment: async (payload, payment) => {
|
||||||
|
// Validate required fields
|
||||||
|
if (!payment.amount) {
|
||||||
|
throw new Error('Amount is required')
|
||||||
|
}
|
||||||
|
if (!payment.currency) {
|
||||||
|
throw new Error('Currency is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate amount
|
||||||
|
if (!isValidAmount(payment.amount)) {
|
||||||
|
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate currency code
|
||||||
|
if (!isValidCurrencyCode(payment.currency)) {
|
||||||
|
throw new Error('Invalid currency: must be a 3-letter ISO code')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique test payment ID
|
||||||
|
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
|
// Create test payment session
|
||||||
|
const session = {
|
||||||
|
id: testPaymentId,
|
||||||
|
payment: { ...payment },
|
||||||
|
createdAt: new Date(),
|
||||||
|
status: 'pending' as PaymentOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
testPaymentSessions.set(testPaymentId, session)
|
||||||
|
|
||||||
|
// Set provider ID and data
|
||||||
|
payment.providerId = testPaymentId
|
||||||
|
const providerData: ProviderData = {
|
||||||
|
raw: {
|
||||||
|
id: testPaymentId,
|
||||||
|
amount: payment.amount,
|
||||||
|
currency: payment.currency,
|
||||||
|
description: payment.description,
|
||||||
|
status: 'pending',
|
||||||
|
testMode: true,
|
||||||
|
paymentUrl: `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`,
|
||||||
|
scenarios: scenarios.map(s => ({ id: s.id, name: s.name, description: s.description })),
|
||||||
|
methods: Object.entries(PAYMENT_METHODS).map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
name: value.name,
|
||||||
|
icon: value.icon
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: 'test'
|
||||||
|
}
|
||||||
|
payment.providerData = providerData
|
||||||
|
|
||||||
|
return payment
|
||||||
|
},
|
||||||
|
} satisfies PaymentProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to process test payment based on scenario
|
||||||
|
async function processTestPayment(
|
||||||
|
payload: Payload,
|
||||||
|
session: TestPaymentSession,
|
||||||
|
pluginConfig: BillingPluginConfig
|
||||||
|
): Promise<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
|
||||||
|
|
||||||
|
// 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 `<!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('Failed to poll status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
${testModeIndicators.consoleWarnings !== false ? `
|
||||||
|
console.warn('🧪 TEST MODE: This is a simulated payment interface for development purposes');
|
||||||
|
` : ''}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
@@ -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 { Config, Payload } from 'payload'
|
||||||
import type { BillingPluginConfig } from '../plugin/config.js'
|
import type { BillingPluginConfig } from '../plugin/config'
|
||||||
|
|
||||||
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
|
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import type { Payment } from '../plugin/types/payments.js'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { BillingPluginConfig } from '../plugin/config.js'
|
import type { BillingPluginConfig } from '../plugin/config'
|
||||||
import type { ProviderData } from './types.js'
|
import type { ProviderData } from './types'
|
||||||
import { defaults } from '../plugin/config.js'
|
import { defaults } from '../plugin/config'
|
||||||
import { extractSlug, toPayloadId } from '../plugin/utils.js'
|
import { extractSlug, toPayloadId } from '../plugin/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common webhook response utilities
|
* Common webhook response utilities
|
||||||
|
|||||||
Reference in New Issue
Block a user