From d08bb221ec5e41ed156a794567af54c52d0fc325 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Tue, 16 Sep 2025 23:27:13 +0200 Subject: [PATCH] feat: Add Stripe provider implementation with webhook support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 56 ++++++---- package.json | 5 +- pnpm-lock.yaml | 20 ++-- src/providers/index.ts | 1 + src/providers/stripe.ts | 227 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 src/providers/stripe.ts diff --git a/README.md b/README.md index 09d9687..1d43012 100644 --- a/README.md +++ b/README.md @@ -24,30 +24,46 @@ pnpm 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 ```typescript import { buildConfig } from 'payload' -import { billingPlugin } from '@xtr-dev/payload-billing' +import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing' export default buildConfig({ // ... your config plugins: [ billingPlugin({ - providers: { - stripe: { + providers: [ + stripeProvider({ secretKey: process.env.STRIPE_SECRET_KEY!, - publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!, - webhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }, - mollie: { + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + }), + mollieProvider({ apiKey: process.env.MOLLIE_API_KEY!, - webhookUrl: process.env.MOLLIE_WEBHOOK_URL!, - }, - test: { - enabled: process.env.NODE_ENV === 'development', - autoComplete: true, - } + webhookUrl: process.env.MOLLIE_WEBHOOK_URL, + }), + ], + collections: { + payments: 'payments', + invoices: 'invoices', + refunds: 'refunds', } }) ] @@ -60,11 +76,11 @@ export default buildConfig({ // Main plugin import { billingPlugin } from '@xtr-dev/payload-billing' -// Provider utilities -import { getPaymentProvider } from '@xtr-dev/payload-billing' +// Payment providers +import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing' // Types -import type { PaymentProvider, CreatePaymentOptions, Payment } from '@xtr-dev/payload-billing' +import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing' ``` ## Provider Types @@ -83,16 +99,14 @@ Local development testing with configurable scenarios, automatic completion, deb The plugin adds these collections: - **payments** - Payment transactions with status and provider data -- **customers** - Customer profiles with billing information - **invoices** - Invoice generation with line items and PDF support - **refunds** - Refund tracking and management ## Webhook Endpoints -Automatic webhook endpoints are created: -- `/api/billing/webhooks/stripe` -- `/api/billing/webhooks/mollie` -- `/api/billing/webhooks/test` +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 diff --git a/package.json b/package.json index bd691ce..2d35286 100644 --- a/package.json +++ b/package.json @@ -100,16 +100,17 @@ "rimraf": "3.0.2", "sharp": "0.34.2", "sort-package-json": "^2.10.0", + "stripe": "^18.5.0", "typescript": "5.7.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.2" }, "peerDependencies": { "@mollie/api-client": "^3.7.0", - "payload": "^3.37.0" + "payload": "^3.37.0", + "stripe": "^18.5.0" }, "dependencies": { - "stripe": "^14.15.0", "zod": "^3.22.4" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2677bf..971afd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - stripe: - specifier: ^14.15.0 - version: 14.25.0 zod: specifier: ^3.22.4 version: 3.25.76 @@ -111,6 +108,9 @@ importers: sort-package-json: specifier: ^2.10.0 version: 2.15.1 + stripe: + specifier: ^18.5.0 + version: 18.5.0(@types/node@22.18.1) typescript: specifier: 5.7.3 version: 5.7.3 @@ -5165,9 +5165,14 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} - stripe@14.25.0: - resolution: {integrity: sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==} + stripe@18.5.0: + resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} @@ -11434,10 +11439,11 @@ snapshots: dependencies: js-tokens: 9.0.1 - stripe@14.25.0: + stripe@18.5.0(@types/node@22.18.1): dependencies: - '@types/node': 22.18.1 qs: 6.14.0 + optionalDependencies: + '@types/node': 22.18.1 strtok3@10.3.4: dependencies: diff --git a/src/providers/index.ts b/src/providers/index.ts index e18301a..4b1c4dc 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,2 +1,3 @@ export * from './mollie' +export * from './stripe' export * from './types' diff --git a/src/providers/stripe.ts b/src/providers/stripe.ts new file mode 100644 index 0000000..5897d69 --- /dev/null +++ b/src/providers/stripe.ts @@ -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(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 +} \ No newline at end of file