Some database adapters don't support transactions, causing payment updates to fail completely. This change adds graceful fallback to direct updates when transactions are unavailable. Changes: - Try to use transactions if supported - Fall back to direct update if beginTransaction() fails or returns null - Add debug logging to track which path is used - Maintain backward compatibility with transaction-supporting databases This fixes the "Failed to begin transaction" error in production. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
@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.
Table of Contents
- Features
- Installation
- Quick Start
- Payment Providers
- Configuration
- Collections
- Payment Flows
- Usage Examples
- Webhook Setup
- API Reference
- TypeScript Support
- Security
- Troubleshooting
- Development
Features
- 💳 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
Installation
npm install @xtr-dev/payload-billing
# or
pnpm add @xtr-dev/payload-billing
# or
yarn add @xtr-dev/payload-billing
Provider Dependencies
Payment providers are peer dependencies and must be installed separately:
# For Stripe support
npm install stripe
# For Mollie support
npm install @mollie/api-client
The test provider requires no additional dependencies.
Quick Start
Basic Setup
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,
}),
],
})
]
})
Create Your First Payment
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, useproviderData.raw.client_secretwith Stripe.js on frontend - Mollie:
providerId= Transaction ID, redirect user tocheckoutUrlto complete payment - Test:
providerId= Test payment ID, navigate tocheckoutUrlfor interactive test UI
Payment Providers
Stripe
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:
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:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Mollie
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:
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:
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:3000only 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")
Test Provider
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:
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:
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:
- Create a test payment
- Navigate to the payment URL in
providerData.raw.paymentUrl - Select payment method and scenario
- Submit to process payment
Configuration
Basic Setup
Minimal configuration with a single provider:
import { billingPlugin, stripeProvider } from '@xtr-dev/payload-billing'
billingPlugin({
providers: [
stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
})
]
})
Multiple Providers
Use multiple payment providers simultaneously:
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',
})
]
})
Customer Management
Option 1: Customer Relationship with Auto-Sync
Link invoices to an existing customer collection and automatically populate customer data:
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:
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:
billingPlugin({
providers: [/* ... */],
// No customerRelationSlug
})
Behavior:
- No customer relationship field
- Customer info fields are required and editable
Collection Slugs
Customize collection names:
billingPlugin({
providers: [/* ... */],
collections: {
payments: 'transactions', // Default: 'payments'
invoices: 'bills', // Default: 'invoices'
refunds: 'chargebacks', // Default: 'refunds'
}
})
Provider Configuration Reference
| 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
Payments
Tracks payment transactions with provider integration.
Fields:
{
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<string, any> // 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
succeeded
Per-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:
// 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
Invoices
Generate and manage invoices with line items and customer information.
Fields:
{
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<string, any>
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
paidAttimestamp set when status becomes 'paid'- Linked payment updated when invoice marked as paid
- Customer info auto-populated if extractor configured
Refunds
Track refunds associated with payments.
Fields:
{
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<string, any>
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
- Full refund: payment status →
- Refunds tracked in payment's
refundsarray
Payment Flows
Stripe Payment Flow
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
Mollie Payment Flow
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
Test Provider Flow
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
Creating a Payment with Stripe
// 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
Creating an Invoice with Line Items
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}`)
Linking Payment to Invoice
// 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
}
})
Creating a Refund
// 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'
Using Customer Relationships
// 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
Querying with Relations
// 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`)
Using Metadata
// 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
Stripe Webhook Configuration
-
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.*andcharge.refundedevents - Copy the signing secret (
whsec_...)
-
Add to environment:
STRIPE_WEBHOOK_SECRET=whsec_... -
Test locally with Stripe CLI:
stripe listen --forward-to localhost:3000/api/payload-billing/stripe/webhook stripe trigger payment_intent.succeeded
Events Handled:
payment_intent.succeeded→ Updates payment status tosucceededpayment_intent.failed→ Updates payment status tofailedpayment_intent.canceled→ Updates payment status tocanceledcharge.refunded→ Updates payment status torefundedorpartially_refunded
Mollie Webhook Configuration
-
Set server URL (webhook URL is auto-generated):
# 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 -
Mollie automatically calls webhook for payment status updates
-
Test locally with ngrok:
ngrok http 3000 # Use ngrok URL as NEXT_PUBLIC_SERVER_URL
Important:
- Mollie requires HTTPS URLs (no
http://orlocalhostin production) - Webhook URL auto-generated from
NEXT_PUBLIC_SERVER_URL,PAYLOAD_PUBLIC_SERVER_URL, orSERVER_URL - In production, throws an error if no valid server URL is configured
- Mollie validates webhooks by verifying payment ID exists
Webhook Security
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
Plugin Configuration
type BillingPluginConfig = {
providers?: PaymentProvider[]
collections?: {
payments?: string
invoices?: string
refunds?: string
}
customerRelationSlug?: string
customerInfoExtractor?: CustomerInfoExtractor
}
Provider Types
type PaymentProvider = {
key: string
onConfig?: (config: Config, pluginConfig: BillingPluginConfig) => void
onInit?: (payload: Payload) => Promise<void> | void
initPayment: (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>>
}
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
}
}
Customer Info Extractor
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')
}
Provider Data
type ProviderData<T = any> = {
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:
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<Payment> => {
return await payload.create({
collection: 'payments',
data: {
provider: 'stripe',
amount,
currency,
status: 'pending' as PaymentStatus
}
})
}
Security
Best Practices
-
Always use webhook secrets in production:
stripeProvider({ secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET! // Required }) -
Use HTTPS in production:
- Stripe requires HTTPS for webhooks
- Mollie requires HTTPS for all URLs
-
Validate amounts:
- Amounts are validated automatically (must be positive integers)
- Currency codes validated against ISO 4217
-
Use optimistic locking:
- Payment updates use version field to prevent conflicts
- Automatic retry logic for concurrent updates
-
Secure customer data:
- Use customer relationships instead of duplicating data
- Implement proper access control on collections
-
Test webhook handling:
- Use Stripe CLI or test provider for local testing
- Verify webhook signatures are checked
Security Features
- 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
Webhook Not Receiving Events
Stripe:
# Check webhook endpoint is accessible
curl -X POST https://yourdomain.com/api/payload-billing/stripe/webhook
# Test with Stripe CLI
stripe listen --forward-to localhost:3000/api/payload-billing/stripe/webhook
stripe trigger payment_intent.succeeded
# Check webhook secret is correct
echo $STRIPE_WEBHOOK_SECRET
Mollie:
# Verify server URL is set (any of these work):
echo $NEXT_PUBLIC_SERVER_URL
echo $PAYLOAD_PUBLIC_SERVER_URL
echo $SERVER_URL
# Check webhook URL is accessible (must be HTTPS in production)
curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \
-d "id=tr_test123"
Payment Status Not Updating
- Check webhook logs in Stripe/Mollie dashboard
- Verify webhook secret is configured correctly
- Check database transactions are supported
- Look for version conflicts (optimistic locking failures)
- Verify payment exists with matching
providerId
Invoice Not Updating After Payment
-
Check payment-invoice link exists:
const payment = await payload.findByID({ collection: 'payments', id: paymentId, depth: 1 }) console.log(payment.invoice) // Should be populated -
Verify payment status is
succeededorpaid -
Check collection hooks are not disabled
Mollie "Invalid URL" Error
- Mollie requires HTTPS URLs in production
- Use ngrok or deploy to staging for local testing:
ngrok http 3000 # Set NEXT_PUBLIC_SERVER_URL to ngrok URL
Test Provider Payment Not Processing
-
Verify test provider is enabled:
testProvider({ enabled: true }) -
Check payment URL in
providerData.raw.paymentUrl -
Navigate to payment UI and manually select scenario
-
Check console logs for processing status
Amount Formatting Issues
Stripe and Test Provider:
- Use cents/smallest currency unit (integer)
- Example: $50.00 =
5000
Mollie:
- Formatted automatically as decimal string
- Example: $50.00 →
"50.00"
Non-decimal currencies (JPY, KRW, etc.):
- No decimal places
- Example: ¥5000 =
5000
TypeScript Errors
# Ensure types are installed
pnpm add -D @types/node
# Check PayloadCMS version
pnpm list payload # Should be ^3.37.0 or higher
Development
Local Development Setup
# Clone repository
git clone https://github.com/xtr-dev/payload-billing.git
cd payload-billing
# Install dependencies
pnpm install
# Build plugin
pnpm build
# Run development server
pnpm dev
Testing
# Run tests
pnpm test
# Type checking
pnpm typecheck
# Linting
pnpm lint
# Build for production
pnpm build
Project Structure
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
Contributing
Contributions welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Requirements
- PayloadCMS: ^3.37.0
- Node.js: ^18.20.2 || >=20.9.0
- Package Manager: pnpm ^9 || ^10
License
MIT
Links
Support
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- PayloadCMS Discord: Join Discord