Bas van den Aakster f2ab50214b fix: add fallback for databases without transaction support
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>
2025-12-05 15:33:18 +01:00

@xtr-dev/payload-billing

npm version

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

  • 💳 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, 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 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: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")

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:

  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 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
  • paidAt timestamp 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
  • Refunds tracked in payment's refunds array

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

  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:

    STRIPE_WEBHOOK_SECRET=whsec_...
    
  3. 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 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

Mollie Webhook Configuration

  1. 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
    
  2. Mollie automatically calls webhook for payment status updates

  3. Test locally with ngrok:

    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

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

  1. Always use webhook secrets in production:

    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

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

  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

Invoice Not Updating After Payment

  1. Check payment-invoice link exists:

    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 paid

  3. 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

  1. Verify test provider is enabled:

    testProvider({ enabled: true })
    
  2. Check payment URL in providerData.raw.paymentUrl

  3. Navigate to payment UI and manually select scenario

  4. 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:

  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)
  5. Open a Pull Request

Requirements

  • PayloadCMS: ^3.37.0
  • Node.js: ^18.20.2 || >=20.9.0
  • Package Manager: pnpm ^9 || ^10

License

MIT

Support

Description
No description provided
Readme 738 KiB
Languages
HTML 68.3%
TypeScript 31%
JavaScript 0.7%