mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
feat: Add Stripe provider implementation with webhook support
- Implement Stripe payment provider with PaymentIntent creation - Add webhook handler with signature verification and event processing - Handle payment status updates and refund events - Move Stripe to peer dependencies for better compatibility - Update README with peer dependency installation instructions - Document new provider configuration patterns and webhook endpoints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
56
README.md
56
README.md
@@ -24,30 +24,46 @@ pnpm add @xtr-dev/payload-billing
|
|||||||
yarn add @xtr-dev/payload-billing
|
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:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import { billingPlugin } from '@xtr-dev/payload-billing'
|
import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
// ... your config
|
// ... your config
|
||||||
plugins: [
|
plugins: [
|
||||||
billingPlugin({
|
billingPlugin({
|
||||||
providers: {
|
providers: [
|
||||||
stripe: {
|
stripeProvider({
|
||||||
secretKey: process.env.STRIPE_SECRET_KEY!,
|
secretKey: process.env.STRIPE_SECRET_KEY!,
|
||||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
|
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||||
webhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
}),
|
||||||
},
|
mollieProvider({
|
||||||
mollie: {
|
|
||||||
apiKey: process.env.MOLLIE_API_KEY!,
|
apiKey: process.env.MOLLIE_API_KEY!,
|
||||||
webhookUrl: process.env.MOLLIE_WEBHOOK_URL!,
|
webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
|
||||||
},
|
}),
|
||||||
test: {
|
],
|
||||||
enabled: process.env.NODE_ENV === 'development',
|
collections: {
|
||||||
autoComplete: true,
|
payments: 'payments',
|
||||||
}
|
invoices: 'invoices',
|
||||||
|
refunds: 'refunds',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
@@ -60,11 +76,11 @@ export default buildConfig({
|
|||||||
// Main plugin
|
// Main plugin
|
||||||
import { billingPlugin } from '@xtr-dev/payload-billing'
|
import { billingPlugin } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
// Provider utilities
|
// Payment providers
|
||||||
import { getPaymentProvider } from '@xtr-dev/payload-billing'
|
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { PaymentProvider, CreatePaymentOptions, Payment } from '@xtr-dev/payload-billing'
|
import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Provider Types
|
## Provider Types
|
||||||
@@ -83,16 +99,14 @@ Local development testing with configurable scenarios, automatic completion, deb
|
|||||||
The plugin adds these collections:
|
The plugin adds these collections:
|
||||||
|
|
||||||
- **payments** - Payment transactions with status and provider data
|
- **payments** - Payment transactions with status and provider data
|
||||||
- **customers** - Customer profiles with billing information
|
|
||||||
- **invoices** - Invoice generation with line items and PDF support
|
- **invoices** - Invoice generation with line items and PDF support
|
||||||
- **refunds** - Refund tracking and management
|
- **refunds** - Refund tracking and management
|
||||||
|
|
||||||
## Webhook Endpoints
|
## Webhook Endpoints
|
||||||
|
|
||||||
Automatic webhook endpoints are created:
|
Automatic webhook endpoints are created for configured providers:
|
||||||
- `/api/billing/webhooks/stripe`
|
- `/api/payload-billing/stripe/webhook` - Stripe payment notifications
|
||||||
- `/api/billing/webhooks/mollie`
|
- `/api/payload-billing/mollie/webhook` - Mollie payment notifications
|
||||||
- `/api/billing/webhooks/test`
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -100,16 +100,17 @@
|
|||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"sharp": "0.34.2",
|
"sharp": "0.34.2",
|
||||||
"sort-package-json": "^2.10.0",
|
"sort-package-json": "^2.10.0",
|
||||||
|
"stripe": "^18.5.0",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.1.2"
|
"vitest": "^3.1.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mollie/api-client": "^3.7.0",
|
"@mollie/api-client": "^3.7.0",
|
||||||
"payload": "^3.37.0"
|
"payload": "^3.37.0",
|
||||||
|
"stripe": "^18.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"stripe": "^14.15.0",
|
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -8,9 +8,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
stripe:
|
|
||||||
specifier: ^14.15.0
|
|
||||||
version: 14.25.0
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.22.4
|
specifier: ^3.22.4
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -111,6 +108,9 @@ importers:
|
|||||||
sort-package-json:
|
sort-package-json:
|
||||||
specifier: ^2.10.0
|
specifier: ^2.10.0
|
||||||
version: 2.15.1
|
version: 2.15.1
|
||||||
|
stripe:
|
||||||
|
specifier: ^18.5.0
|
||||||
|
version: 18.5.0(@types/node@22.18.1)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.7.3
|
specifier: 5.7.3
|
||||||
version: 5.7.3
|
version: 5.7.3
|
||||||
@@ -5165,9 +5165,14 @@ packages:
|
|||||||
strip-literal@3.0.0:
|
strip-literal@3.0.0:
|
||||||
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
|
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
|
||||||
|
|
||||||
stripe@14.25.0:
|
stripe@18.5.0:
|
||||||
resolution: {integrity: sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==}
|
resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==}
|
||||||
engines: {node: '>=12.*'}
|
engines: {node: '>=12.*'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=12.x.x'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
strtok3@10.3.4:
|
strtok3@10.3.4:
|
||||||
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
||||||
@@ -11434,10 +11439,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
stripe@14.25.0:
|
stripe@18.5.0(@types/node@22.18.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.18.1
|
|
||||||
qs: 6.14.0
|
qs: 6.14.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.18.1
|
||||||
|
|
||||||
strtok3@10.3.4:
|
strtok3@10.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './mollie'
|
export * from './mollie'
|
||||||
|
export * from './stripe'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|||||||
227
src/providers/stripe.ts
Normal file
227
src/providers/stripe.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import type { Payment } from '@/plugin/types/payments'
|
||||||
|
import type { PaymentProvider } from '@/plugin/types'
|
||||||
|
import type { Config, Payload } from 'payload'
|
||||||
|
import { createSingleton } from '@/plugin/singleton'
|
||||||
|
import type Stripe from 'stripe'
|
||||||
|
import { defaults } from '@/plugin/config'
|
||||||
|
import { extractSlug } from '@/plugin/utils'
|
||||||
|
|
||||||
|
const symbol = Symbol('stripe')
|
||||||
|
|
||||||
|
export interface StripeProviderConfig {
|
||||||
|
secretKey: string
|
||||||
|
webhookSecret?: string
|
||||||
|
apiVersion?: Stripe.StripeConfig['apiVersion']
|
||||||
|
returnUrl?: string
|
||||||
|
webhookUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||||
|
const singleton = createSingleton<Stripe>(symbol)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'stripe',
|
||||||
|
onConfig: (config, pluginConfig) => {
|
||||||
|
config.endpoints = [
|
||||||
|
...(config.endpoints || []),
|
||||||
|
{
|
||||||
|
path: '/payload-billing/stripe/webhook',
|
||||||
|
method: 'post',
|
||||||
|
handler: async (req) => {
|
||||||
|
try {
|
||||||
|
const payload = req.payload
|
||||||
|
const stripe = singleton.get(payload)
|
||||||
|
|
||||||
|
// Get the raw body for signature verification
|
||||||
|
if (!req.text) {
|
||||||
|
return Response.json({ error: 'Missing request body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.text()
|
||||||
|
const signature = req.headers.get('stripe-signature')
|
||||||
|
|
||||||
|
if (!signature || !stripeConfig.webhookSecret) {
|
||||||
|
return Response.json({ error: 'Missing webhook signature or secret' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify webhook signature and construct event
|
||||||
|
let event: Stripe.Event
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Stripe Webhook] Signature verification failed:', err)
|
||||||
|
return Response.json({ error: 'Invalid signature' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different event types
|
||||||
|
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'payment_intent.succeeded':
|
||||||
|
case 'payment_intent.payment_failed':
|
||||||
|
case 'payment_intent.canceled': {
|
||||||
|
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
||||||
|
|
||||||
|
// Find the corresponding payment in our database
|
||||||
|
const payments = await payload.find({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
where: {
|
||||||
|
providerId: {
|
||||||
|
equals: paymentIntent.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (payments.docs.length === 0) {
|
||||||
|
console.error(`[Stripe Webhook] Payment not found for intent: ${paymentIntent.id}`)
|
||||||
|
return Response.json({ received: true }, { status: 200 }) // Still return 200 to acknowledge receipt
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentDoc = payments.docs[0]
|
||||||
|
|
||||||
|
// Map Stripe status to our status
|
||||||
|
let status: Payment['status'] = 'pending'
|
||||||
|
|
||||||
|
if (paymentIntent.status === 'succeeded') {
|
||||||
|
status = 'succeeded'
|
||||||
|
} else if (paymentIntent.status === 'canceled') {
|
||||||
|
status = 'canceled'
|
||||||
|
} else if (paymentIntent.status === 'requires_payment_method' ||
|
||||||
|
paymentIntent.status === 'requires_confirmation' ||
|
||||||
|
paymentIntent.status === 'requires_action') {
|
||||||
|
status = 'pending'
|
||||||
|
} else if (paymentIntent.status === 'processing') {
|
||||||
|
status = 'processing'
|
||||||
|
} else {
|
||||||
|
status = 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the payment status and provider data
|
||||||
|
await payload.update({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
id: paymentDoc.id,
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
providerData: paymentIntent as any
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// If payment is successful and linked to an invoice, update the invoice
|
||||||
|
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
|
||||||
|
const payment = paymentDoc as Payment
|
||||||
|
|
||||||
|
if (status === 'succeeded' && payment.invoice) {
|
||||||
|
const invoiceId = typeof payment.invoice === 'object'
|
||||||
|
? payment.invoice.id
|
||||||
|
: payment.invoice
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: invoicesCollection,
|
||||||
|
id: invoiceId,
|
||||||
|
data: {
|
||||||
|
status: 'paid',
|
||||||
|
payment: paymentDoc.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'charge.refunded': {
|
||||||
|
const charge = event.data.object as Stripe.Charge
|
||||||
|
|
||||||
|
// Find the payment by charge ID (which might be stored in providerData)
|
||||||
|
const payments = await payload.find({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
where: {
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
providerId: {
|
||||||
|
equals: charge.payment_intent as string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: {
|
||||||
|
equals: charge.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (payments.docs.length > 0) {
|
||||||
|
const paymentDoc = payments.docs[0]
|
||||||
|
|
||||||
|
// Determine if fully or partially refunded
|
||||||
|
const isFullyRefunded = charge.amount_refunded === charge.amount
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
id: paymentDoc.id,
|
||||||
|
data: {
|
||||||
|
status: isFullyRefunded ? 'refunded' : 'partially_refunded',
|
||||||
|
providerData: charge as any
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unhandled event type
|
||||||
|
console.log(`[Stripe Webhook] Unhandled event type: ${event.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ received: true }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Stripe Webhook] Error processing webhook:', error)
|
||||||
|
return Response.json({
|
||||||
|
error: 'Webhook processing failed',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
onInit: async (payload: Payload) => {
|
||||||
|
const { default: Stripe } = await import('stripe')
|
||||||
|
const stripe = new Stripe(stripeConfig.secretKey, {
|
||||||
|
apiVersion: stripeConfig.apiVersion || '2024-11-20.acacia',
|
||||||
|
})
|
||||||
|
singleton.set(payload, stripe)
|
||||||
|
},
|
||||||
|
initPayment: async (payload, payment) => {
|
||||||
|
if (!payment.amount) {
|
||||||
|
throw new Error('Amount is required')
|
||||||
|
}
|
||||||
|
if (!payment.currency) {
|
||||||
|
throw new Error('Currency is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = singleton.get(payload)
|
||||||
|
|
||||||
|
// Create a payment intent
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount: payment.amount,
|
||||||
|
currency: payment.currency.toLowerCase(),
|
||||||
|
description: payment.description || undefined,
|
||||||
|
metadata: {
|
||||||
|
payloadPaymentId: payment.id?.toString() || '',
|
||||||
|
...(typeof payment.metadata === 'object' && payment.metadata !== null ? payment.metadata : {})
|
||||||
|
},
|
||||||
|
automatic_payment_methods: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
payment.providerId = paymentIntent.id
|
||||||
|
payment.providerData = {
|
||||||
|
...paymentIntent,
|
||||||
|
clientSecret: paymentIntent.client_secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
return payment
|
||||||
|
},
|
||||||
|
} satisfies PaymentProvider
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user