Bas van den Aakster 27da194942 feat: add automatic payment/invoice status sync and invoice view page
Core Plugin Enhancements:
- Add afterChange hook to payments collection to auto-update linked invoice status to 'paid' when payment succeeds
- Add afterChange hook to invoices collection for bidirectional payment-invoice relationship management
- Add invoice status sync when manually marked as paid
- Update plugin config types to support collection extension options

Demo Application Features:
- Add professional invoice view page with print-friendly layout (/invoice/[id])
- Add custom message field to payment creation form
- Add invoice API endpoint to fetch complete invoice data with customer info
- Add payment API endpoint to retrieve payment with invoice relationship
- Update payment success page with "View Invoice" button
- Implement beforeChange hook to copy custom message from payment metadata to invoice
- Remove customer collection dependency - use direct customerInfo fields instead

Documentation:
- Update README with automatic status synchronization section
- Add collection extension examples to demo README
- Document new features: bidirectional relationships, status sync, invoice view

Technical Improvements:
- Fix total calculation in invoice API (use 'amount' field instead of 'total')
- Add proper TypeScript types with CollectionSlug casting
- Implement Next.js 15 async params pattern in API routes
- Add customer name/email/company fields to payment creation form

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 16:20:01 +01:00

@xtr-dev/payload-billing

npm version

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.

Table of Contents

Features

  • 💳 Multiple payment providers (Stripe, Mollie, Test)
  • 🧾 Invoice generation and management with embedded customer info
  • 👥 Flexible customer data management with relationship support
  • 📊 Complete payment tracking and history
  • 🪝 Secure webhook processing for all providers
  • 🔄 Automatic payment/invoice status synchronization
  • 🧪 Built-in test provider for local development
  • 📱 Payment management in PayloadCMS admin
  • 🔗 Bidirectional payment-invoice relationship management
  • 🎨 Collection extension support for custom fields and hooks
  • 🔒 Full TypeScript support

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 based on which providers you plan to use:

# For Stripe support
npm install stripe
# or
pnpm add stripe

# For Mollie support
npm install @mollie/api-client
# or
pnpm add @mollie/api-client

Quick Start

Basic Configuration

import { buildConfig } from 'payload'
import { billingPlugin, stripeProvider, mollieProvider } 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,
        }),
        mollieProvider({
          apiKey: process.env.MOLLIE_API_KEY!,
          webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
        }),
      ],
      collections: {
        payments: 'payments',
        invoices: 'invoices',
        refunds: 'refunds',
      }
    })
  ]
})

With Customer Management

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

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

// Main plugin
import { billingPlugin } from '@xtr-dev/payload-billing'

// Payment providers
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'

// Types
import type { 
  PaymentProvider, 
  Payment, 
  Invoice, 
  Refund,
  BillingPluginConfig,
  CustomerInfoExtractor,
  MollieProviderConfig,
  StripeProviderConfig,
  ProviderData
} from '@xtr-dev/payload-billing'

Provider Types

Stripe

Credit card payments, subscriptions, webhook processing, automatic payment method storage.

Mollie

European payment methods (iDEAL, SEPA, etc.), multi-currency support, refund processing.

Test Provider

Local development testing with configurable scenarios, automatic completion, debug mode.

Collections

The plugin adds these collections:

  • payments - Payment transactions with status and provider data
  • invoices - Invoice generation with line items and embedded customer info
  • refunds - Refund tracking and management

Automatic Status Synchronization

The plugin automatically keeps payments and invoices in sync:

  • Payment → Invoice: When a payment status changes to paid or succeeded, any linked invoice is automatically updated to paid status
  • Invoice → Payment: When an invoice is created with a payment link, the payment is automatically linked back (bidirectional relationship)
  • Manual Invoice Payment: When an invoice status is manually changed to paid, the linked payment is updated to succeeded

This ensures data consistency without manual intervention and works seamlessly with webhook updates from payment providers.

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

Usage Examples

Creating a Payment

Payments are created through PayloadCMS's local API or REST API. The plugin automatically initializes the payment with the configured provider.

// Using Payload Local API
const payment = await payload.create({
  collection: 'payments',
  data: {
    provider: 'stripe', // or 'mollie' or 'test'
    amount: 2000, // Amount in cents ($20.00)
    currency: 'USD',
    description: 'Product purchase',
    status: 'pending',
    metadata: {
      orderId: 'order-123',
      customerId: 'cust-456'
    }
  }
})

Creating an Invoice

Invoices can be created with customer information embedded or linked via relationship:

// Create invoice with embedded customer info
const invoice = await payload.create({
  collection: 'invoices',
  data: {
    customerInfo: {
      name: 'John Doe',
      email: 'john@example.com',
      phone: '+1234567890',
      company: 'Acme Corp',
      taxId: 'TAX-123'
    },
    billingAddress: {
      line1: '123 Main St',
      line2: 'Suite 100',
      city: 'New York',
      state: 'NY',
      postalCode: '10001',
      country: 'US'
    },
    currency: 'USD',
    items: [
      {
        description: 'Web Development Services',
        quantity: 10,
        unitAmount: 5000 // $50.00 per hour
      },
      {
        description: 'Hosting (Monthly)',
        quantity: 1,
        unitAmount: 2500 // $25.00
      }
    ],
    taxAmount: 7500, // $75.00 tax
    status: 'open'
  }
})

console.log(`Invoice created: ${invoice.number}`)
console.log(`Total amount: $${invoice.amount / 100}`)

Creating an Invoice with Customer Relationship

If you've configured a customer collection with customerRelationSlug and customerInfoExtractor:

// Create invoice linked to customer (info auto-populated)
const invoice = await payload.create({
  collection: 'invoices',
  data: {
    customer: 'customer-id-123', // Customer relationship
    currency: 'USD',
    items: [
      {
        description: 'Subscription - Pro Plan',
        quantity: 1,
        unitAmount: 9900 // $99.00
      }
    ],
    status: 'open'
    // customerInfo and billingAddress are auto-populated from customer
  }
})

Creating a Refund

Refunds are linked to existing payments:

const refund = await payload.create({
  collection: 'refunds',
  data: {
    payment: payment.id, // Link to payment
    providerId: 'refund-provider-id', // Provider's refund ID
    amount: 1000, // Partial refund: $10.00
    currency: 'USD',
    status: 'succeeded',
    reason: 'requested_by_customer',
    description: 'Customer requested partial refund'
  }
})

Querying Payments

// Find all successful payments
const payments = await payload.find({
  collection: 'payments',
  where: {
    status: {
      equals: 'succeeded'
    }
  }
})

// Find payments for a specific invoice
const invoicePayments = await payload.find({
  collection: 'payments',
  where: {
    invoice: {
      equals: invoiceId
    }
  }
})

Updating Payment Status

Payment status is typically updated via webhooks, but you can also update manually:

const updatedPayment = await payload.update({
  collection: 'payments',
  id: payment.id,
  data: {
    status: 'succeeded',
    providerData: {
      // Provider-specific data
      raw: providerResponse,
      timestamp: new Date().toISOString(),
      provider: 'stripe'
    }
  }
})

Marking an Invoice as Paid

const paidInvoice = await payload.update({
  collection: 'invoices',
  id: invoice.id,
  data: {
    status: 'paid',
    payment: payment.id // Link to payment
    // paidAt is automatically set by the plugin
  }
})

Using the Test Provider

The test provider is useful for local development:

// In your payload.config.ts
import { billingPlugin, testProvider } from '@xtr-dev/payload-billing'

billingPlugin({
  providers: [
    testProvider({
      enabled: true,
      testModeIndicators: {
        showWarningBanners: true,
        showTestBadges: true,
        consoleWarnings: true
      }
    })
  ],
  collections: {
    payments: 'payments',
    invoices: 'invoices',
    refunds: 'refunds',
  }
})

Then create test payments:

const testPayment = await payload.create({
  collection: 'payments',
  data: {
    provider: 'test',
    amount: 5000,
    currency: 'USD',
    description: 'Test payment',
    status: 'pending'
  }
})
// Test provider automatically processes the payment

Using REST API

All collections can be accessed via PayloadCMS REST API:

# Create a payment
curl -X POST http://localhost:3000/api/payments \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "provider": "stripe",
    "amount": 2000,
    "currency": "USD",
    "description": "Product purchase",
    "status": "pending"
  }'

# Create an invoice
curl -X POST http://localhost:3000/api/invoices \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "customerInfo": {
      "name": "John Doe",
      "email": "john@example.com"
    },
    "billingAddress": {
      "line1": "123 Main St",
      "city": "New York",
      "postalCode": "10001",
      "country": "US"
    },
    "currency": "USD",
    "items": [
      {
        "description": "Service",
        "quantity": 1,
        "unitAmount": 5000
      }
    ],
    "status": "open"
  }'

# Get all payments
curl http://localhost:3000/api/payments \
  -H "Authorization: Bearer YOUR_TOKEN"

# Get a specific invoice
curl http://localhost:3000/api/invoices/INVOICE_ID \
  -H "Authorization: Bearer YOUR_TOKEN"

Webhook Endpoints

Automatic webhook endpoints are created for configured providers:

  • /api/payload-billing/stripe/webhook - Stripe payment notifications
  • /api/payload-billing/mollie/webhook - Mollie payment notifications

Requirements

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

Development

# Install dependencies
pnpm install

# Build plugin
pnpm build

# Run tests
pnpm test

# Development server
pnpm dev

License

MIT

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