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

Billing Plugin Demo Application

This is a demo application showcasing the @xtr-dev/payload-billing plugin for PayloadCMS 3.x.

Features

  • 🧪 Test Payment Provider with customizable scenarios
  • 💳 Payment Management with full CRUD operations
  • 🧾 Invoice Generation with line items and tax calculation
  • 🔄 Automatic Status Sync - payments and invoices stay in sync automatically
  • 🔗 Bidirectional Relationships - payment/invoice links maintained by plugin hooks
  • 🎨 Custom Payment UI with modern design
  • 📄 Invoice View Page - professional printable invoice layout
  • 🔧 Collection Extensions - demonstrates how to extend collections with custom fields and hooks
  • 💬 Custom Message Field - shows hook-based data copying from payment to invoice
  • 📊 No Customer Collection Required - uses direct customer info fields

Getting Started

Installation

# Install dependencies
pnpm install

Running the Demo

# Start the development server
pnpm dev

# The application will be available at http://localhost:3000

Default Credentials

  • Email: dev@payloadcms.com
  • Password: test

Demo Routes

Interactive Demo Page

Visit http://localhost:3000 to access the interactive demo page where you can:

  • Create test payments
  • View the custom payment UI
  • Test different payment scenarios
  • Navigate to admin collections

Custom Payment UI

The custom test payment UI is available at:

http://localhost:3000/test-payment/{payment-id}

This page demonstrates:

  • Modern, responsive payment interface
  • Payment method selection
  • Test scenario selection (success, failure, cancellation, etc.)
  • Real-time payment status updates
  • Test mode indicators and warnings

Invoice View Page

View and print invoices at:

http://localhost:3000/invoice/{invoice-id}

This page demonstrates:

  • Professional printable invoice layout
  • Customer billing information
  • Line items table with quantities and amounts
  • Tax calculations and totals
  • Custom message field (populated from payment metadata)
  • Print-friendly styling

Admin Routes

Sample Data

The application includes seed data with:

  • 2 Customers

    • John Doe (Acme Corporation)
    • Jane Smith (Tech Innovations Inc.)
  • 2 Invoices

    • Paid invoice with web development services
    • Open invoice with subscription and additional users
  • 4 Payments

    • Successful payment linked to invoice
    • Pending payment linked to invoice
    • Standalone successful payment
    • Failed payment example
  • 1 Refund

    • Partial refund on a successful payment

To reset the sample data:

# Delete the database file
rm dev/payload.sqlite

# Restart the server (will re-seed automatically)
pnpm dev

Configuration

The plugin is configured in dev/payload.config.ts with:

Test Provider Setup

testProvider({
  enabled: true,
  testModeIndicators: {
    showWarningBanners: true,
    showTestBadges: true,
    consoleWarnings: true
  },
  customUiRoute: '/test-payment',
})

Collection Extension Options

This demo showcases how to extend the plugin's collections with custom fields and hooks. The invoices collection is extended to include a customMessage field that is automatically populated from payment metadata:

collections: {
  payments: 'payments',
  invoices: {
    slug: 'invoices',
    extend: (config) => ({
      ...config,
      fields: [
        ...(config.fields || []),
        {
          name: 'customMessage',
          type: 'textarea',
          admin: {
            description: 'Custom message from the payment (auto-populated)',
          },
        },
      ],
      hooks: {
        ...config.hooks,
        beforeChange: [
          ...(config.hooks?.beforeChange || []),
          async ({ data, req, operation }) => {
            if (operation === 'create' && data.payment) {
              const payment = await req.payload.findByID({
                collection: 'payments',
                id: typeof data.payment === 'object' ? data.payment.id : data.payment,
              })

              if (
                payment?.metadata &&
                typeof payment.metadata === 'object' &&
                'customMessage' in payment.metadata &&
                payment.metadata.customMessage
              ) {
                data.customMessage = payment.metadata.customMessage as string
              }
            }
            return data
          },
        ],
      },
    }),
  },
  refunds: 'refunds',
}

Customer Relationship

customerRelationSlug: 'customers',
customerInfoExtractor: (customer) => ({
  name: customer.name,
  email: customer.email,
  phone: customer.phone,
  company: customer.company,
  taxId: customer.taxId,
  billingAddress: customer.address ? {
    line1: customer.address.line1,
    line2: customer.address.line2,
    city: customer.address.city,
    state: customer.address.state,
    postalCode: customer.address.postalCode,
    country: customer.address.country,
  } : undefined,
})

Test Payment Scenarios

The test provider includes the following scenarios:

  1. Instant Success - Payment succeeds immediately
  2. Delayed Success - Payment succeeds after a delay (3s)
  3. Cancelled Payment - User cancels the payment (1s)
  4. Declined Payment - Payment is declined by the provider (2s)
  5. Expired Payment - Payment expires before completion (5s)
  6. Pending Payment - Payment remains in pending state (1.5s)

Payment Methods

The test provider supports these payment methods:

  • 🏦 iDEAL
  • 💳 Credit Card
  • 🅿️ PayPal
  • 🍎 Apple Pay
  • 🏛️ Bank Transfer

API Examples

Creating a Payment (Local API)

import { getPayload } from 'payload'
import configPromise from '@payload-config'

const payload = await getPayload({ config: configPromise })

const payment = await payload.create({
  collection: 'payments',
  data: {
    provider: 'test',
    amount: 2500, // $25.00 in cents
    currency: 'USD',
    description: 'Demo payment',
    status: 'pending',
  }
})

// The payment will have a providerId that can be used in the custom UI
console.log(`Payment URL: /test-payment/${payment.providerId}`)

Creating an Invoice with Customer

const invoice = await payload.create({
  collection: 'invoices',
  data: {
    customer: 'customer-id-here',
    currency: 'USD',
    items: [
      {
        description: 'Service',
        quantity: 1,
        unitAmount: 5000 // $50.00
      }
    ],
    taxAmount: 500, // $5.00
    status: 'open'
  }
})

REST API Example

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

Custom Routes

The demo includes custom API routes:

Create Payment

POST /api/demo/create-payment

Request body:

{
  "amount": 2500,
  "currency": "USD",
  "description": "Demo payment",
  "message": "Custom message to include in the invoice (optional)"
}

The message field will be stored in the payment's metadata and automatically copied to the invoice when it's created, thanks to the collection extension hook.

Response:

{
  "success": true,
  "payment": {
    "id": "test_pay_1234567890_abc123",
    "paymentId": "67890",
    "amount": 2500,
    "currency": "USD",
    "description": "Demo payment"
  }
}

Get Payment

GET /api/demo/payment/{payment-provider-id}

Fetches payment details including invoice relationship. Used by the payment success page to find the associated invoice.

Response:

{
  "success": true,
  "payment": {
    "id": "67890",
    "providerId": "test_pay_1234567890_abc123",
    "amount": 2500,
    "currency": "USD",
    "status": "paid",
    "description": "Demo payment",
    "invoice": "invoice-id-here",
    "metadata": {
      "customMessage": "Your custom message"
    }
  }
}

Get Invoice

GET /api/demo/invoice/{invoice-id}

Fetches complete invoice data including customer details, line items, and custom message. Used by the invoice view page.

Response:

{
  "success": true,
  "invoice": {
    "id": "invoice-id",
    "invoiceNumber": "INV-2024-001",
    "customer": {
      "name": "John Doe",
      "email": "john@example.com",
      "company": "Acme Corp"
    },
    "currency": "USD",
    "items": [
      {
        "description": "Service",
        "quantity": 1,
        "unitAmount": 2500
      }
    ],
    "subtotal": 2500,
    "taxAmount": 250,
    "total": 2750,
    "status": "paid",
    "customMessage": "Your custom message from payment"
  }
}

Development

File Structure

dev/
├── app/
│   ├── page.tsx                     # Interactive demo page (root)
│   ├── test-payment/
│   │   └── [id]/
│   │       └── page.tsx             # Custom payment UI
│   ├── invoice/
│   │   └── [id]/
│   │       └── page.tsx             # Invoice view/print page
│   ├── payment-success/
│   │   └── page.tsx                 # Payment success page
│   ├── payment-failed/
│   │   └── page.tsx                 # Payment failed page
│   ├── api/
│   │   └── demo/
│   │       ├── create-payment/
│   │       │   └── route.ts         # Payment creation endpoint
│   │       ├── invoice/
│   │       │   └── [id]/
│   │       │       └── route.ts     # Invoice fetch endpoint
│   │       └── payment/
│   │           └── [id]/
│   │               └── route.ts     # Payment fetch endpoint
│   └── (payload)/                   # PayloadCMS admin routes
├── helpers/
│   └── credentials.ts               # Default user credentials
├── payload.config.ts                # PayloadCMS configuration
├── seed.ts                          # Sample data seeding
└── README.md                        # This file

Modifying the Demo

To customize the demo:

  1. Add more test scenarios: Edit the testProvider config in payload.config.ts
  2. Customize the payment UI: Edit app/test-payment/[id]/page.tsx
  3. Add more sample data: Edit seed.ts
  4. Add custom collections: Add to collections array in payload.config.ts

Testing Different Providers

To test with real payment providers:

// Install the provider
pnpm add stripe
// or
pnpm add @mollie/api-client

// Update payload.config.ts
import { stripeProvider, mollieProvider } from '../src/providers'

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,
    }),
    // Keep test provider for development
    testProvider({ enabled: true }),
  ],
  // ... rest of config
})

Troubleshooting

Database Issues

If you encounter database errors:

# Delete the database
rm dev/payload.sqlite

# Regenerate types
pnpm dev:generate-types

# Restart the server
pnpm dev

Port Already in Use

If port 3000 is already in use:

# Use a different port
PORT=3001 pnpm dev

TypeScript Errors

Regenerate Payload types:

pnpm dev:generate-types

Resources

License

MIT