diff --git a/README.md b/README.md index 1559ccf..453e31d 100644 --- a/README.md +++ b/README.md @@ -2,38 +2,50 @@ [![npm version](https://badge.fury.io/js/@xtr-dev%2Fpayload-billing.svg)](https://badge.fury.io/js/@xtr-dev%2Fpayload-billing) -A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Mollie, and local testing with comprehensive tracking and flexible customer data management. +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 currently in active development (v0.1.x). Breaking changes may occur before v1.0.0. Not recommended for production use. +โš ๏ธ **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](#features) - [Installation](#installation) - [Quick Start](#quick-start) -- [Imports](#imports) -- [Usage Examples](#usage-examples) - - [Creating a Payment](#creating-a-payment) - - [Creating an Invoice](#creating-an-invoice) - - [Creating a Refund](#creating-a-refund) - - [Querying Payments](#querying-payments) - - [Using REST API](#using-rest-api) -- [Provider Types](#provider-types) +- [Payment Providers](#payment-providers) + - [Stripe](#stripe) + - [Mollie](#mollie) + - [Test Provider](#test-provider) +- [Configuration](#configuration) + - [Basic Setup](#basic-setup) + - [Customer Management](#customer-management) + - [Provider Configuration](#provider-configuration) - [Collections](#collections) -- [Webhook Endpoints](#webhook-endpoints) + - [Payments](#payments) + - [Invoices](#invoices) + - [Refunds](#refunds) +- [Payment Flows](#payment-flows) +- [Usage Examples](#usage-examples) +- [Webhook Setup](#webhook-setup) +- [API Reference](#api-reference) +- [TypeScript Support](#typescript-support) +- [Security](#security) +- [Troubleshooting](#troubleshooting) - [Development](#development) ## Features -- ๐Ÿ’ณ Multiple payment providers (Stripe, Mollie, Test) -- ๐Ÿงพ Invoice generation with line items and tax calculation -- ๐Ÿ‘ฅ Flexible customer data management (relationship or embedded) -- ๐Ÿ”„ Automatic payment/invoice status synchronization -- ๐Ÿช Secure webhook processing for all providers -- ๐Ÿ”— Bidirectional payment-invoice-refund relationships -- ๐ŸŽจ Collection extension support for custom fields and hooks -- ๐Ÿงช Built-in test provider for local development -- ๐Ÿ”’ Full TypeScript support +- ๐Ÿ’ณ **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 @@ -47,27 +59,25 @@ 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: +Payment providers are peer dependencies and must be installed separately: ```bash # For Stripe support npm install stripe -# or -pnpm add stripe # For Mollie support npm install @mollie/api-client -# or -pnpm add @mollie/api-client ``` +The test provider requires no additional dependencies. + ## Quick Start -### Basic Configuration +### Basic Setup ```typescript import { buildConfig } from 'payload' -import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing' +import { billingPlugin, stripeProvider } from '@xtr-dev/payload-billing' export default buildConfig({ // ... your config @@ -78,27 +88,222 @@ export default buildConfig({ 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 +### Create Your First Payment ```typescript -import { billingPlugin, CustomerInfoExtractor } from '@xtr-dev/payload-billing' +const payment = await payload.create({ + collection: 'payments', + data: { + provider: 'stripe', + amount: 5000, // $50.00 in cents + currency: 'USD', + description: 'Product purchase', + status: 'pending', + } +}) + +// Payment is automatically initialized with Stripe +console.log(payment.providerId) // Stripe PaymentIntent ID +``` + +## 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:** + +```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_... +``` + +### 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:** + +```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 +PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com +``` + +**Important Notes:** +- Mollie requires HTTPS URLs in production (no localhost) +- Webhook URL defaults to `{PAYLOAD_PUBLIC_SERVER_URL}/api/payload-billing/mollie/webhook` +- 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:** + +```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 payment + +## Configuration + +### Basic Setup + +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, + }) + ] +}) +``` + +### Multiple Providers + +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', + }) + ] +}) +``` + +### Customer Management + +#### 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' -// Define how to extract customer info from your customer collection const customerExtractor: CustomerInfoExtractor = (customer) => ({ name: customer.name, email: customer.email, @@ -116,401 +321,941 @@ const customerExtractor: CustomerInfoExtractor = (customer) => ({ }) billingPlugin({ - // ... providers - collections: { - payments: 'payments', - invoices: 'invoices', - refunds: 'refunds', - }, - customerRelationSlug: 'customers', // Enable customer relationships - customerInfoExtractor: customerExtractor, // Auto-sync customer data + providers: [/* ... */], + customerRelationSlug: 'customers', + customerInfoExtractor: customerExtractor, }) ``` -### Custom Customer Data Extraction +**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 -import { CustomerInfoExtractor } from '@xtr-dev/payload-billing' +billingPlugin({ + providers: [/* ... */], + customerRelationSlug: 'customers', + // No customerInfoExtractor +}) +``` -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, +**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** + +### Collection Slugs + +Customize collection names: + +```typescript +billingPlugin({ + providers: [/* ... */], + collections: { + payments: 'transactions', // Default: 'payments' + invoices: 'bills', // Default: 'invoices' + refunds: 'chargebacks', // Default: 'refunds' } }) - -billingPlugin({ - // ... other config - customerRelationSlug: 'clients', - customerInfoExtractor: customExtractor, -}) ``` -## Imports +### Provider Configuration Reference -```typescript -// 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. +| 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 -The plugin adds these collections: +### Payments -- **payments** - Payment transactions with status and provider data -- **invoices** - Invoice generation with line items and embedded customer info -- **refunds** - Refund tracking and management +Tracks payment transactions with provider integration. -### Automatic Status Synchronization +**Fields:** -The plugin automatically keeps payments and invoices in sync: +```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 + 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 +} +``` -- **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` +**Status Flow:** -This ensures data consistency without manual intervention and works seamlessly with webhook updates from payment providers. +``` +pending โ†’ processing โ†’ succeeded + โ†’ failed + โ†’ canceled +succeeded โ†’ partially_refunded โ†’ refunded +``` -### Customer Data Management +**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` -The plugin supports flexible customer data handling: +### Invoices -1. **With Customer Relationship + Extractor**: Customer relationship required, customer info auto-populated and read-only, syncs automatically when customer changes +Generate and manage invoices with line items and customer information. -2. **With Customer Relationship (no extractor)**: Customer relationship optional, customer info manually editable, either relationship OR customer info required +**Fields:** -3. **No Customer Collection**: Customer info fields always required and editable, no relationship field available +```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 + +### Refunds + +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` 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 - -Payments are created through PayloadCMS's local API or REST API. The plugin automatically initializes the payment with the configured provider. +### Creating a Payment with Stripe ```typescript -// Using Payload Local API +// Create payment const payment = await payload.create({ collection: 'payments', data: { - provider: 'stripe', // or 'mollie' or 'test' - amount: 2000, // Amount in cents ($20.00) + provider: 'stripe', + amount: 2000, // $20.00 currency: 'USD', - description: 'Product purchase', + description: 'Premium subscription', status: 'pending', metadata: { - orderId: 'order-123', - customerId: 'cust-456' + customerId: 'cust_123', + planId: 'premium' } } }) + +// Get client secret for Stripe.js +const clientSecret = payment.providerData.raw.client_secret + +// Frontend: Confirm payment with Stripe.js +// const stripe = Stripe('pk_...') +// await stripe.confirmCardPayment(clientSecret, { ... }) ``` -### Creating an Invoice - -Invoices can be created with customer information embedded or linked via relationship: +### Creating an Invoice with Line Items ```typescript -// Create invoice with embedded customer info const invoice = await payload.create({ collection: 'invoices', data: { customerInfo: { - name: 'John Doe', - email: 'john@example.com', - phone: '+1234567890', + name: 'Acme Corporation', + email: 'billing@acme.com', company: 'Acme Corp', - taxId: 'TAX-123' + taxId: 'US123456789' }, billingAddress: { - line1: '123 Main St', - line2: 'Suite 100', - city: 'New York', - state: 'NY', - postalCode: '10001', + line1: '123 Business Blvd', + city: 'San Francisco', + state: 'CA', + postalCode: '94102', country: 'US' }, currency: 'USD', items: [ { - description: 'Web Development Services', - quantity: 10, - unitAmount: 5000 // $50.00 per hour + description: 'Website Development', + quantity: 40, + unitAmount: 15000 // $150/hour }, { - description: 'Hosting (Monthly)', + description: 'Hosting (Annual)', quantity: 1, - unitAmount: 2500 // $25.00 + unitAmount: 50000 // $500 } ], - taxAmount: 7500, // $75.00 tax - status: 'open' + taxAmount: 65000, // $650 (10% tax) + dueDate: '2025-12-31', + status: 'open', + notes: 'Payment due within 30 days' } }) -console.log(`Invoice created: ${invoice.number}`) -console.log(`Total amount: $${invoice.amount / 100}`) +// 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}`) ``` -### Creating an Invoice with Customer Relationship - -If you've configured a customer collection with `customerRelationSlug` and `customerInfoExtractor`: +### Linking Payment to Invoice ```typescript -// Create invoice linked to customer (info auto-populated) -const invoice = await payload.create({ - collection: 'invoices', +// Create payment for specific invoice +const payment = await payload.create({ + collection: 'payments', 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 + 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 -Refunds are linked to existing payments: - ```typescript +// Full refund 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', + payment: payment.id, + amount: payment.amount, // Full amount + currency: payment.currency, status: 'succeeded', reason: 'requested_by_customer', - description: 'Customer requested partial refund' + 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' ``` -### Querying Payments +### Using Customer Relationships ```typescript -// Find all successful payments +// 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 + +```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: { - status: { - equals: 'succeeded' - } - } -}) - -// Find payments for a specific invoice -const invoicePayments = await payload.find({ collection: 'payments', where: { invoice: { - equals: invoiceId - } + in: customerInvoices.docs.map(inv => inv.id) + }, + status: { equals: 'succeeded' } } }) -``` -### Updating Payment Status - -Payment status is typically updated via webhooks, but you can also update manually: - -```typescript -const updatedPayment = await payload.update({ +// Find all refunds for a payment +const payment = await payload.findByID({ collection: 'payments', - id: payment.id, - data: { - status: 'succeeded', - providerData: { - // Provider-specific data - raw: providerResponse, - timestamp: new Date().toISOString(), - provider: 'stripe' - } - } + id: paymentId, + depth: 2 // Include refund details }) + +console.log(`Payment has ${payment.refunds?.length || 0} refunds`) ``` -### Marking an Invoice as Paid +### Using Metadata ```typescript -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: - -```typescript -// 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: - -```typescript -const testPayment = await payload.create({ +// Store custom data with payment +const payment = await payload.create({ collection: 'payments', data: { - provider: 'test', + provider: 'stripe', amount: 5000, currency: 'USD', - description: 'Test payment', - status: 'pending' + 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' } } }) -// Test provider automatically processes the payment ``` -### Using REST API +## Webhook Setup -All collections can be accessed via PayloadCMS REST API: +### 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:** + ```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` + +### Mollie Webhook Configuration + +1. **Set webhook URL:** + ```bash + MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook + PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com + ``` + +2. **Mollie automatically calls webhook** for payment status updates + +3. **Test locally with ngrok:** + ```bash + ngrok http 3000 + # Use ngrok URL as PAYLOAD_PUBLIC_SERVER_URL + ``` + +**Important:** +- Mollie requires HTTPS URLs (no `http://` or `localhost` in production) +- Webhook URL defaults to `{PAYLOAD_PUBLIC_SERVER_URL}/api/payload-billing/mollie/webhook` +- 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 + +```typescript +type BillingPluginConfig = { + providers?: PaymentProvider[] + collections?: { + payments?: string + invoices?: string + refunds?: string + } + customerRelationSlug?: string + customerInfoExtractor?: CustomerInfoExtractor +} +``` + +### Provider Types + +```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 + } +} +``` + +### Customer Info Extractor + +```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') +} +``` + +### Provider Data + +```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 + +### Best Practices + +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 + +### 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:** +```bash +# 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:** +```bash +# Verify PAYLOAD_PUBLIC_SERVER_URL is set +echo $PAYLOAD_PUBLIC_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: + ```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 `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: + ```bash + ngrok http 3000 + # Set PAYLOAD_PUBLIC_SERVER_URL to ngrok URL + ``` + +### Test Provider Payment Not Processing + +1. **Verify test provider is enabled:** + ```typescript + 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 ```bash -# 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" - }' +# Ensure types are installed +pnpm add -D @types/node -# 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" +# Check PayloadCMS version +pnpm list payload # Should be ^3.37.0 or higher ``` -## 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 +### Local Development Setup + ```bash +# 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 + +```bash # Run tests pnpm test -# Development server -pnpm dev +# 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 + +## Links + +- [GitHub Repository](https://github.com/xtr-dev/payload-billing) +- [npm Package](https://www.npmjs.com/package/@xtr-dev/payload-billing) +- [PayloadCMS Documentation](https://payloadcms.com/docs) +- [Issue Tracker](https://github.com/xtr-dev/payload-billing/issues) + +## Support + +- **Issues**: [GitHub Issues](https://github.com/xtr-dev/payload-billing/issues) +- **Discussions**: [GitHub Discussions](https://github.com/xtr-dev/payload-billing/discussions) +- **PayloadCMS Discord**: [Join Discord](https://discord.gg/payload)