PayloadCMS plugin for billing and payment provider integrations with tracking and local testing
npm install @xtr-dev/payload-billing
A comprehensive billing and payment provider plugin for PayloadCMS 3.x with support for Stripe, Mollie, and local testing. Features automatic payment/invoice synchronization, webhook processing, and flexible customer data management.
โ ๏ธ Pre-release Warning: This package is in active development (v0.1.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
- Features
- Installation
- Quick Start
- Payment Providers
- Stripe
- Mollie
- Test Provider
- Configuration
- Basic Setup
- Customer Management
- Provider Configuration
- Collections
- Payments
- Invoices
- Refunds
- Payment Flows
- Usage Examples
- Webhook Setup
- API Reference
- TypeScript Support
- Security
- Troubleshooting
- Development
- ๐ณ Multiple Payment Providers - Stripe, Mollie, and Test provider support
- ๐งพ Invoice Management - Generate invoices with line items, tax calculation, and automatic numbering
- ๐ฅ Flexible Customer Data - Use relationships to existing collections or embedded customer info
- ๐ Automatic Synchronization - Payment and invoice statuses sync bidirectionally
- ๐ช Secure Webhooks - Production-ready webhook handling with signature verification
- ๐ Bidirectional Relations - Payment-invoice-refund relationships automatically maintained
- ๐จ Collection Extension - Add custom fields and hooks to all collections
- ๐งช Testing Tools - Built-in test provider with configurable payment scenarios
- ๐ Type-Safe - Full TypeScript support with comprehensive type definitions
- โก Optimistic Locking - Prevents concurrent payment status update conflicts
- ๐ฐ Multi-Currency - Support for 100+ currencies with proper decimal handling
- ๐ Production Ready - Transaction support, error handling, and security best practices
``bash`
npm install @xtr-dev/payload-billingor
pnpm add @xtr-dev/payload-billingor
yarn add @xtr-dev/payload-billing
Payment providers are peer dependencies and must be installed separately:
`bashFor Stripe support
npm install stripe
The test provider requires no additional dependencies.
Quick Start
$3
`typescript
import { buildConfig } from 'payload'
import { billingPlugin, stripeProvider } from '@xtr-dev/payload-billing'export default buildConfig({
// ... your config
plugins: [
billingPlugin({
providers: [
stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
],
})
]
})
`$3
`typescript
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'stripe', // or 'mollie' or 'test'
amount: 5000, // $50.00 in cents
currency: 'USD',
description: 'Product purchase',
status: 'pending',
}
})
`What you get back:
- Stripe:
providerId = PaymentIntent ID, use providerData.raw.client_secret with Stripe.js on frontend
- Mollie: providerId = Transaction ID, redirect user to checkoutUrl to complete payment
- Test: providerId = Test payment ID, navigate to checkoutUrl for interactive test UIPayment Providers
$3
Full-featured credit card processing with support for multiple payment methods, subscriptions, and refunds.
Features:
- Credit/debit cards, digital wallets (Apple Pay, Google Pay)
- Automatic payment method detection
- Strong Customer Authentication (SCA) support
- Comprehensive webhook events
- Full refund support (partial and full)
Configuration:
`typescript
import { stripeProvider } from '@xtr-dev/payload-billing'stripeProvider({
secretKey: string // Required: Stripe secret key (sk_test_... or sk_live_...)
webhookSecret?: string // Recommended: Webhook signing secret (whsec_...)
apiVersion?: string // Optional: API version (default: '2025-08-27.basil')
returnUrl?: string // Optional: Custom return URL after payment
webhookUrl?: string // Optional: Custom webhook URL
})
`Environment Variables:
`bash
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
`$3
European payment service provider supporting iDEAL, SEPA, Bancontact, and other local payment methods.
Features:
- 20+ European payment methods (iDEAL, SEPA, Bancontact, etc.)
- Multi-currency support
- Simple redirect-based flow
- Automatic webhook notifications
Configuration:
`typescript
import { mollieProvider } from '@xtr-dev/payload-billing'mollieProvider({
apiKey: string // Required: Mollie API key (test_... or live_...)
webhookUrl?: string // Optional: Custom webhook URL
redirectUrl?: string // Optional: Custom redirect URL after payment
})
`Environment Variables:
`bash
MOLLIE_API_KEY=test_...
MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook # Optional if server URL is set
NEXT_PUBLIC_SERVER_URL=https://yourdomain.com # Or PAYLOAD_PUBLIC_SERVER_URL or SERVER_URL
`Important Notes:
- Mollie requires HTTPS URLs in production (no localhost)
- Webhook URL is auto-generated from server URL environment variables (checked in order:
NEXT_PUBLIC_SERVER_URL, PAYLOAD_PUBLIC_SERVER_URL, SERVER_URL)
- Falls back to https://localhost:3000 only in non-production environments
- In production, throws an error if no valid URL can be determined
- Amounts are formatted as decimal strings (e.g., "50.00")$3
Local development provider with interactive UI for testing different payment scenarios.
Features:
- Interactive payment UI with scenario selection
- Configurable payment outcomes (success, failure, cancellation, etc.)
- Customizable processing delays
- Multiple payment method simulation
- No external API calls
Configuration:
`typescript
import { testProvider } from '@xtr-dev/payload-billing'testProvider({
enabled: boolean // Required: Must be explicitly enabled
scenarios?: PaymentScenario[] // Optional: Custom scenarios
defaultDelay?: number // Optional: Default processing delay (ms)
baseUrl?: string // Optional: Server URL
testModeIndicators?: {
showWarningBanners?: boolean // Show test mode warnings
showTestBadges?: boolean // Show test badges on UI
consoleWarnings?: boolean // Log test mode warnings
}
})
`Default Scenarios:
| Scenario | Outcome | Delay |
|----------|---------|-------|
| Instant Success |
succeeded | 0ms |
| Delayed Success | succeeded | 3000ms |
| Cancelled Payment | canceled | 1000ms |
| Declined Payment | failed | 2000ms |
| Expired Payment | canceled | 5000ms |
| Pending Payment | pending | 1500ms |Custom Scenarios:
`typescript
testProvider({
enabled: true,
scenarios: [
{
id: 'slow-success',
name: 'Slow Success',
description: 'Payment succeeds after 10 seconds',
outcome: 'paid',
delay: 10000,
method: 'creditcard'
},
{
id: 'instant-fail',
name: 'Instant Failure',
description: 'Payment fails immediately',
outcome: 'failed',
delay: 0,
method: 'ideal'
}
]
})
`Usage:
1. Create a test payment
2. Navigate to the payment URL in
providerData.raw.paymentUrl
3. Select payment method and scenario
4. Submit to process paymentConfiguration
$3
Minimal configuration with a single provider:
`typescript
import { billingPlugin, stripeProvider } from '@xtr-dev/payload-billing'billingPlugin({
providers: [
stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
})
]
})
`$3
Use multiple payment providers simultaneously:
`typescript
billingPlugin({
providers: [
stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
mollieProvider({
apiKey: process.env.MOLLIE_API_KEY!,
webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
}),
testProvider({
enabled: process.env.NODE_ENV === 'development',
})
]
})
`$3
#### Option 1: Customer Relationship with Auto-Sync
Link invoices to an existing customer collection and automatically populate customer data:
`typescript
import { CustomerInfoExtractor } from '@xtr-dev/payload-billing'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: [/ ... /],
customerRelationSlug: 'customers',
customerInfoExtractor: customerExtractor,
})
`Behavior:
- Customer relationship field is required
- Customer info fields are read-only (auto-populated)
- Customer info syncs automatically when customer record changes
#### Option 2: Customer Relationship (Manual Customer Info)
Link to customer collection but manually enter customer data:
`typescript
billingPlugin({
providers: [/ ... /],
customerRelationSlug: 'customers',
// No customerInfoExtractor
})
`Behavior:
- Customer relationship is optional
- Customer info fields are editable
- Either customer relationship OR customer info is required
#### Option 3: No Customer Collection
Store customer data directly on invoices:
`typescript
billingPlugin({
providers: [/ ... /],
// No customerRelationSlug
})
`Behavior:
- No customer relationship field
- Customer info fields are required and editable
$3
Customize collection names:
`typescript
billingPlugin({
providers: [/ ... /],
collections: {
payments: 'transactions', // Default: 'payments'
invoices: 'bills', // Default: 'invoices'
refunds: 'chargebacks', // Default: 'refunds'
}
})
`$3
| Provider | Required Config | Optional Config | Notes |
|----------|----------------|-----------------|-------|
| Stripe |
secretKey | webhookSecret, apiVersion, returnUrl, webhookUrl | Webhook secret highly recommended for production |
| Mollie | apiKey | webhookUrl, redirectUrl | Requires HTTPS in production |
| Test | enabled: true | scenarios, defaultDelay, baseUrl, testModeIndicators | Only for development |Collections
$3
Tracks payment transactions with provider integration.
Fields:
`typescript
{
id: string | number
provider: 'stripe' | 'mollie' | 'test'
providerId: string // Provider's payment ID
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'refunded' | 'partially_refunded'
amount: number // Amount in cents
currency: string // ISO 4217 currency code
description?: string
checkoutUrl?: string // Checkout URL (if applicable)
redirectUrl?: string // URL to redirect user after payment
invoice?: Invoice | string // Linked invoice
metadata?: Record // Custom metadata
providerData?: ProviderData // Raw provider response (read-only)
refunds?: Refund[] // Associated refunds
version: number // For optimistic locking
createdAt: string
updatedAt: string
}
`Status Flow:
`
pending โ processing โ succeeded
โ failed
โ canceled
succeeded โ partially_refunded โ refunded
`Automatic Behaviors:
- Amount must be a positive integer
- Currency normalized to uppercase
- Version incremented on each update
- Provider's
initPayment() called on creation
- Linked invoice updated when status becomes succeededPer-Payment Redirect URLs:
The
redirectUrl field allows customizing where users are redirected after payment completion on a per-payment basis. This is useful when different payments need different destinations:`typescript
// Invoice payment redirects to invoice confirmation
await payload.create({
collection: 'payments',
data: {
provider: 'mollie',
amount: 5000,
currency: 'EUR',
redirectUrl: 'https://example.com/invoices/123/thank-you'
}
})// Subscription payment redirects to subscription page
await payload.create({
collection: 'payments',
data: {
provider: 'mollie',
amount: 1999,
currency: 'EUR',
redirectUrl: 'https://example.com/subscription/confirmed'
}
})
`Priority:
payment.redirectUrl > provider config redirectUrl/returnUrl > default fallback$3
Generate and manage invoices with line items and customer information.
Fields:
`typescript
{
id: string | number
number: string // Auto-generated (INV-YYYYMMDD-XXXX)
customer?: string // Customer relationship (if configured)
customerInfo: {
name: string
email: string
phone?: string
company?: string
taxId?: string
}
billingAddress: {
line1: string
line2?: string
city: string
state?: string
postalCode: string
country: string // ISO 3166-1 alpha-2
}
currency: string // ISO 4217 currency code
items: Array<{
description: string
quantity: number
unitAmount: number // In cents
amount: number // Auto-calculated (quantity ร unitAmount)
}>
subtotal: number // Auto-calculated sum of items
taxAmount?: number
amount: number // Auto-calculated (subtotal + taxAmount)
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'
payment?: Payment | string // Linked payment
dueDate?: string
issuedAt?: string
paidAt?: string // Auto-set when status becomes 'paid'
notes?: string
metadata?: Record
createdAt: string
updatedAt: string
}
`Status Flow:
`
draft โ open โ paid
โ void
โ uncollectible
`Automatic Behaviors:
- Invoice number auto-generated on creation
- Item amounts calculated from quantity ร unitAmount
- Subtotal calculated from sum of item amounts
- Total amount calculated as subtotal + taxAmount
-
paidAt timestamp set when status becomes 'paid'
- Linked payment updated when invoice marked as paid
- Customer info auto-populated if extractor configured$3
Track refunds associated with payments.
Fields:
`typescript
{
id: string | number
payment: Payment | string // Required: linked payment
providerId?: string // Provider's refund ID
amount: number // Refund amount in cents
currency: string // ISO 4217 currency code
status: 'pending' | 'succeeded' | 'failed' | 'canceled'
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer' | 'other'
description?: string
metadata?: Record
providerData?: ProviderData
createdAt: string
updatedAt: string
}
`Automatic Behaviors:
- Payment status updated based on refund amount:
- Full refund: payment status โ
refunded
- Partial refund: payment status โ partially_refunded
- Refunds tracked in payment's refunds arrayPayment Flows
$3
`
1. Create Payment Record
โโ> POST /api/payments { provider: 'stripe', amount: 5000, ... }2. Initialize with Stripe
โโ> stripe.paymentIntents.create()
โโ> Returns: PaymentIntent with client_secret
3. Client Confirms Payment
โโ> Use Stripe.js with client_secret
โโ> User completes payment
4. Stripe Sends Webhook
โโ> POST /api/payload-billing/stripe/webhook
โโ> Event: payment_intent.succeeded
5. Update Payment Status
โโ> Find payment by providerId
โโ> Update status to 'succeeded' (with optimistic locking)
6. Update Invoice Status
โโ> Find linked invoice
โโ> Update invoice status to 'paid'
โโ> Set paidAt timestamp
`$3
`
1. Create Payment Record
โโ> POST /api/payments { provider: 'mollie', amount: 5000, ... }2. Initialize with Mollie
โโ> mollieClient.payments.create()
โโ> Returns: Payment with checkout URL
3. Redirect User to Mollie
โโ> User redirected to Mollie's checkout page
โโ> User completes payment
4. Mollie Sends Webhook
โโ> POST /api/payload-billing/mollie/webhook
โโ> Body: id=tr_xxxxx
5. Fetch Payment Status
โโ> mollieClient.payments.get(id)
โโ> Get latest payment status
6. Update Payment Status
โโ> Map Mollie status to internal status
โโ> Update with optimistic locking
7. Update Invoice Status
โโ> Update linked invoice to 'paid' if payment succeeded
`$3
`
1. Create Payment Record
โโ> POST /api/payments { provider: 'test', amount: 5000, ... }2. Create In-Memory Session
โโ> Generate test payment ID (test_pay_...)
โโ> Store session in memory
โโ> Return payment UI URL
3. User Opens Payment UI
โโ> Navigate to /api/payload-billing/test/payment/{id}
โโ> Interactive HTML form displayed
4. Select Scenario
โโ> Choose payment method (iDEAL, Credit Card, etc.)
โโ> Choose scenario (Success, Failed, etc.)
โโ> Submit form
5. Process Payment
โโ> POST /api/payload-billing/test/process
โโ> Schedule payment processing after delay
โโ> Return processing status
6. Update Payment Status
โโ> After delay, update payment in database
โโ> Map scenario outcome to payment status
โโ> Update linked invoice if succeeded
`Usage Examples
$3
`typescript
// Create payment
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'stripe',
amount: 2000, // $20.00
currency: 'USD',
description: 'Premium subscription',
status: 'pending',
metadata: {
customerId: 'cust_123',
planId: 'premium'
}
}
})// Get client secret for Stripe.js (Stripe doesn't use checkoutUrl)
const clientSecret = payment.providerData.raw.client_secret
// Frontend: Confirm payment with Stripe.js
// const stripe = Stripe('pk_...')
// await stripe.confirmCardPayment(clientSecret, { ... })
// For Mollie/Test: redirect to payment.checkoutUrl instead
`$3
`typescript
const invoice = await payload.create({
collection: 'invoices',
data: {
customerInfo: {
name: 'Acme Corporation',
email: 'billing@acme.com',
company: 'Acme Corp',
taxId: 'US123456789'
},
billingAddress: {
line1: '123 Business Blvd',
city: 'San Francisco',
state: 'CA',
postalCode: '94102',
country: 'US'
},
currency: 'USD',
items: [
{
description: 'Website Development',
quantity: 40,
unitAmount: 15000 // $150/hour
},
{
description: 'Hosting (Annual)',
quantity: 1,
unitAmount: 50000 // $500
}
],
taxAmount: 65000, // $650 (10% tax)
dueDate: '2025-12-31',
status: 'open',
notes: 'Payment due within 30 days'
}
})// Invoice automatically calculated:
// subtotal = (40 ร $150) + (1 ร $500) = $6,500
// amount = $6,500 + $650 = $7,150
console.log(
Invoice ${invoice.number} created for $${invoice.amount / 100})
`$3
`typescript
// Create payment for specific invoice
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'stripe',
amount: invoice.amount,
currency: invoice.currency,
description: Payment for invoice ${invoice.number},
invoice: invoice.id, // Link to invoice
status: 'pending'
}
})// Or update invoice with payment
await payload.update({
collection: 'invoices',
id: invoice.id,
data: {
payment: payment.id
}
})
`$3
`typescript
// Full refund
const refund = await payload.create({
collection: 'refunds',
data: {
payment: payment.id,
amount: payment.amount, // Full amount
currency: payment.currency,
status: 'succeeded',
reason: 'requested_by_customer',
description: 'Customer cancelled order'
}
})
// Payment status automatically updated to 'refunded'// Partial refund
const partialRefund = await payload.create({
collection: 'refunds',
data: {
payment: payment.id,
amount: 1000, // Partial amount ($10.00)
currency: payment.currency,
status: 'succeeded',
reason: 'requested_by_customer',
description: 'Partial refund for damaged item'
}
})
// Payment status automatically updated to 'partially_refunded'
`$3
`typescript
// With customer extractor configured
const invoice = await payload.create({
collection: 'invoices',
data: {
customer: 'customer_id_123', // Customer info auto-populated
currency: 'EUR',
items: [{
description: 'Monthly Subscription',
quantity: 1,
unitAmount: 4900 // โฌ49.00
}],
status: 'open'
}
})
// customerInfo and billingAddress automatically filled from customer record
`$3
`typescript
// Find all payments for a customer's invoices
const customerInvoices = await payload.find({
collection: 'invoices',
where: {
customer: { equals: customerId }
}
})const payments = await payload.find({
collection: 'payments',
where: {
invoice: {
in: customerInvoices.docs.map(inv => inv.id)
},
status: { equals: 'succeeded' }
}
})
// Find all refunds for a payment
const payment = await payload.findByID({
collection: 'payments',
id: paymentId,
depth: 2 // Include refund details
})
console.log(
Payment has ${payment.refunds?.length || 0} refunds)
`$3
`typescript
// Store custom data with payment
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'stripe',
amount: 5000,
currency: 'USD',
metadata: {
orderId: 'order_12345',
customerId: 'cust_67890',
source: 'mobile_app',
campaignId: 'spring_sale_2025',
affiliateCode: 'REF123'
}
}
})// Query by metadata
const campaignPayments = await payload.find({
collection: 'payments',
where: {
'metadata.campaignId': { equals: 'spring_sale_2025' },
status: { equals: 'succeeded' }
}
})
`Webhook Setup
$3
1. Get your webhook signing secret:
- Go to Stripe Dashboard โ Developers โ Webhooks
- Click "Add endpoint"
- URL:
https://yourdomain.com/api/payload-billing/stripe/webhook
- Events to send: Select all payment_intent.* and charge.refunded events
- Copy the signing secret (whsec_...)2. Add to environment:
`bash
STRIPE_WEBHOOK_SECRET=whsec_...
`3. Test locally with Stripe CLI:
`bash
stripe listen --forward-to localhost:3000/api/payload-billing/stripe/webhook
stripe trigger payment_intent.succeeded
`Events Handled:
-
payment_intent.succeeded โ Updates payment status to succeeded
- payment_intent.failed โ Updates payment status to failed
- payment_intent.canceled โ Updates payment status to canceled
- charge.refunded โ Updates payment status to refunded or partially_refunded$3
1. Set server URL (webhook URL is auto-generated):
`bash
# Any of these work (checked in this order):
NEXT_PUBLIC_SERVER_URL=https://yourdomain.com
PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com
SERVER_URL=https://yourdomain.com # Or set explicit webhook URL:
MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook
`2. Mollie automatically calls webhook for payment status updates
3. Test locally with ngrok:
`bash
ngrok http 3000
# Use ngrok URL as NEXT_PUBLIC_SERVER_URL
`Important:
- Mollie requires HTTPS URLs (no
http:// or localhost in production)
- Webhook URL auto-generated from NEXT_PUBLIC_SERVER_URL, PAYLOAD_PUBLIC_SERVER_URL, or SERVER_URL
- In production, throws an error if no valid server URL is configured
- Mollie validates webhooks by verifying payment ID exists$3
All webhook endpoints:
- Return HTTP 200 OK for all requests (prevents replay attacks)
- Validate signatures (Stripe) or payment IDs (Mollie)
- Use optimistic locking to prevent concurrent update conflicts
- Log detailed errors internally but return generic responses
- Run within database transactions for atomicity
API Reference
$3
`typescript
type BillingPluginConfig = {
providers?: PaymentProvider[]
collections?: {
payments?: string
invoices?: string
refunds?: string
}
customerRelationSlug?: string
customerInfoExtractor?: CustomerInfoExtractor
}
`$3
`typescript
type PaymentProvider = {
key: string
onConfig?: (config: Config, pluginConfig: BillingPluginConfig) => void
onInit?: (payload: Payload) => Promise | void
initPayment: (payload: Payload, payment: Partial) => Promise>
}type StripeProviderConfig = {
secretKey: string
webhookSecret?: string
apiVersion?: string
returnUrl?: string
webhookUrl?: string
}
type MollieProviderConfig = {
apiKey: string
webhookUrl?: string
redirectUrl?: string
}
type TestProviderConfig = {
enabled: boolean
scenarios?: PaymentScenario[]
defaultDelay?: number
baseUrl?: string
testModeIndicators?: {
showWarningBanners?: boolean
showTestBadges?: boolean
consoleWarnings?: boolean
}
}
`$3
`typescript
type CustomerInfoExtractor = (
customer: any
) => {
name: string
email: string
phone?: string
company?: string
taxId?: string
billingAddress: Address
}type Address = {
line1: string
line2?: string
city: string
state?: string
postalCode: string
country: string // ISO 3166-1 alpha-2 (e.g., 'US', 'GB', 'NL')
}
`$3
`typescript
type ProviderData = {
raw: T // Raw provider response
timestamp: string // ISO 8601 timestamp
provider: string // Provider key ('stripe', 'mollie', 'test')
}
`TypeScript Support
Full TypeScript support with comprehensive type definitions:
`typescript
import type {
// Main types
Payment,
Invoice,
Refund, // Provider types
PaymentProvider,
StripeProviderConfig,
MollieProviderConfig,
TestProviderConfig,
// Configuration
BillingPluginConfig,
CustomerInfoExtractor,
// Data types
ProviderData,
Address,
InvoiceItem,
CustomerInfo,
// Status types
PaymentStatus,
InvoiceStatus,
RefundStatus,
RefundReason,
// Utility types
InitPayment,
PaymentScenario,
} from '@xtr-dev/payload-billing'
// Use in your code
const createPayment = async (
payload: Payload,
amount: number,
currency: string
): Promise => {
return await payload.create({
collection: 'payments',
data: {
provider: 'stripe',
amount,
currency,
status: 'pending' as PaymentStatus
}
})
}
`Security
$3
1. Always use webhook secrets in production:
`typescript
stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET! // Required
})
`2. Use HTTPS in production:
- Stripe requires HTTPS for webhooks
- Mollie requires HTTPS for all URLs
3. Validate amounts:
- Amounts are validated automatically (must be positive integers)
- Currency codes validated against ISO 4217
4. Use optimistic locking:
- Payment updates use version field to prevent conflicts
- Automatic retry logic for concurrent updates
5. Secure customer data:
- Use customer relationships instead of duplicating data
- Implement proper access control on collections
6. Test webhook handling:
- Use Stripe CLI or test provider for local testing
- Verify webhook signatures are checked
$3
- Webhook Signature Verification - Stripe webhooks validated with HMAC-SHA256
- Optimistic Locking - Version field prevents concurrent update conflicts
- Transaction Support - Database transactions ensure atomicity
- Error Concealment - Generic error responses prevent information disclosure
- Input Validation - Amount, currency, and URL validation
- Read-Only Provider Data - Raw provider responses immutable in admin UI
Troubleshooting
$3
Stripe:
`bash
Check webhook endpoint is accessible
curl -X POST https://yourdomain.com/api/payload-billing/stripe/webhookTest with Stripe CLI
stripe listen --forward-to localhost:3000/api/payload-billing/stripe/webhook
stripe trigger payment_intent.succeededCheck webhook secret is correct
echo $STRIPE_WEBHOOK_SECRET
`Mollie:
`bash
Verify server URL is set (any of these work):
echo $NEXT_PUBLIC_SERVER_URL
echo $PAYLOAD_PUBLIC_SERVER_URL
echo $SERVER_URLCheck webhook URL is accessible (must be HTTPS in production)
curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \
-d "id=tr_test123"
`$3
1. Check webhook logs in Stripe/Mollie dashboard
2. Verify webhook secret is configured correctly
3. Check database transactions are supported
4. Look for version conflicts (optimistic locking failures)
5. Verify payment exists with matching
providerId$3
1. Check payment-invoice link exists:
`typescript
const payment = await payload.findByID({
collection: 'payments',
id: paymentId,
depth: 1
})
console.log(payment.invoice) // Should be populated
`2. Verify payment status is
succeeded or paid3. Check collection hooks are not disabled
$3
- Mollie requires HTTPS URLs in production
- Use ngrok or deploy to staging for local testing:
`bash
ngrok http 3000
# Set NEXT_PUBLIC_SERVER_URL to ngrok URL
`$3
1. Verify test provider is enabled:
`typescript
testProvider({ enabled: true })
`2. Check payment URL in
providerData.raw.paymentUrl3. Navigate to payment UI and manually select scenario
4. Check console logs for processing status
$3
Stripe and Test Provider:
- Use cents/smallest currency unit (integer)
- Example: $50.00 =
5000Mollie:
- Formatted automatically as decimal string
- Example: $50.00 โ
"50.00"Non-decimal currencies (JPY, KRW, etc.):
- No decimal places
- Example: ยฅ5000 =
5000$3
`bash
Ensure types are installed
pnpm add -D @types/nodeCheck PayloadCMS version
pnpm list payload # Should be ^3.37.0 or higher
`Development
$3
`bash
Clone repository
git clone https://github.com/xtr-dev/payload-billing.git
cd payload-billingInstall dependencies
pnpm installBuild plugin
pnpm buildRun development server
pnpm dev
`$3
`bash
Run tests
pnpm testType checking
pnpm typecheckLinting
pnpm lintBuild for production
pnpm build
`$3
`
payload-billing/
โโโ src/
โ โโโ collections/ # Collection definitions
โ โ โโโ payments.ts
โ โ โโโ invoices.ts
โ โ โโโ refunds.ts
โ โโโ providers/ # Payment providers
โ โ โโโ stripe.ts
โ โ โโโ mollie.ts
โ โ โโโ test.ts
โ โ โโโ types.ts
โ โ โโโ utils.ts
โ โโโ plugin/ # Plugin core
โ โ โโโ index.ts
โ โ โโโ types/
โ โ โโโ singleton.ts
โ โโโ index.ts # Public exports
โโโ dev/ # Development/testing app
โโโ dist/ # Built files
`$3
Contributions welcome! Please:
1. Fork the repository
2. Create a feature branch (
git checkout -b feature/amazing-feature)
3. Commit your changes (git commit -m 'Add amazing feature')
4. Push to the branch (git push origin feature/amazing-feature`)- PayloadCMS: ^3.37.0
- Node.js: ^18.20.2 || >=20.9.0
- Package Manager: pnpm ^9 || ^10
MIT
- GitHub Repository
- npm Package
- PayloadCMS Documentation
- Issue Tracker
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- PayloadCMS Discord: Join Discord