From 7e4ec86e00807bc399ac1294b22b9d3cb1a8d96d Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Tue, 2 Dec 2025 20:57:21 +0100 Subject: [PATCH] feat: add per-payment redirectUrl field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add redirectUrl field to payments collection for custom redirect destinations - Update Mollie provider to use payment.redirectUrl with config fallback - Update Stripe provider to pass redirectUrl as return_url - Update test provider to redirect to payment-specific URL on success - Fix production URL detection to check NEXT_PUBLIC_SERVER_URL first - Update README with redirectUrl documentation and examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 59 ++++++++++++++++++++++++++++++------ package.json | 2 +- src/collections/payments.ts | 7 +++++ src/plugin/types/payments.ts | 4 +++ src/providers/mollie.ts | 21 ++++++++++--- src/providers/stripe.ts | 4 +++ src/providers/test.ts | 28 ++++++++++------- 7 files changed, 100 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ac3be6d..254892d 100644 --- a/README.md +++ b/README.md @@ -175,13 +175,15 @@ mollieProvider({ ```bash MOLLIE_API_KEY=test_... -MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook -PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com +MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook # Optional if server URL is set +NEXT_PUBLIC_SERVER_URL=https://yourdomain.com # Or PAYLOAD_PUBLIC_SERVER_URL or SERVER_URL ``` **Important Notes:** - Mollie requires HTTPS URLs in production (no localhost) -- Webhook URL defaults to `{PAYLOAD_PUBLIC_SERVER_URL}/api/payload-billing/mollie/webhook` +- Webhook URL is auto-generated from server URL environment variables (checked in order: `NEXT_PUBLIC_SERVER_URL`, `PAYLOAD_PUBLIC_SERVER_URL`, `SERVER_URL`) +- Falls back to `https://localhost:3000` only in non-production environments +- In production, throws an error if no valid URL can be determined - Amounts are formatted as decimal strings (e.g., "50.00") ### Test Provider @@ -408,6 +410,7 @@ Tracks payment transactions with provider integration. currency: string // ISO 4217 currency code description?: string checkoutUrl?: string // Checkout URL (if applicable) + redirectUrl?: string // URL to redirect user after payment invoice?: Invoice | string // Linked invoice metadata?: Record // Custom metadata providerData?: ProviderData // Raw provider response (read-only) @@ -434,6 +437,36 @@ succeeded → partially_refunded → refunded - Provider's `initPayment()` called on creation - Linked invoice updated when status becomes `succeeded` +**Per-Payment Redirect URLs:** + +The `redirectUrl` field allows customizing where users are redirected after payment completion on a per-payment basis. This is useful when different payments need different destinations: + +```typescript +// Invoice payment redirects to invoice confirmation +await payload.create({ + collection: 'payments', + data: { + provider: 'mollie', + amount: 5000, + currency: 'EUR', + redirectUrl: 'https://example.com/invoices/123/thank-you' + } +}) + +// Subscription payment redirects to subscription page +await payload.create({ + collection: 'payments', + data: { + provider: 'mollie', + amount: 1999, + currency: 'EUR', + redirectUrl: 'https://example.com/subscription/confirmed' + } +}) +``` + +**Priority:** `payment.redirectUrl` > provider config `redirectUrl`/`returnUrl` > default fallback + ### Invoices Generate and manage invoices with line items and customer information. @@ -864,10 +897,15 @@ const campaignPayments = await payload.find({ ### Mollie Webhook Configuration -1. **Set webhook URL:** +1. **Set server URL** (webhook URL is auto-generated): ```bash - MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook + # Any of these work (checked in this order): + NEXT_PUBLIC_SERVER_URL=https://yourdomain.com PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com + SERVER_URL=https://yourdomain.com + + # Or set explicit webhook URL: + MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook ``` 2. **Mollie automatically calls webhook** for payment status updates @@ -875,12 +913,13 @@ const campaignPayments = await payload.find({ 3. **Test locally with ngrok:** ```bash ngrok http 3000 - # Use ngrok URL as PAYLOAD_PUBLIC_SERVER_URL + # Use ngrok URL as NEXT_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` +- Webhook URL auto-generated from `NEXT_PUBLIC_SERVER_URL`, `PAYLOAD_PUBLIC_SERVER_URL`, or `SERVER_URL` +- In production, throws an error if no valid server URL is configured - Mollie validates webhooks by verifying payment ID exists ### Webhook Security @@ -1096,8 +1135,10 @@ echo $STRIPE_WEBHOOK_SECRET **Mollie:** ```bash -# Verify PAYLOAD_PUBLIC_SERVER_URL is set +# Verify server URL is set (any of these work): +echo $NEXT_PUBLIC_SERVER_URL echo $PAYLOAD_PUBLIC_SERVER_URL +echo $SERVER_URL # Check webhook URL is accessible (must be HTTPS in production) curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \ @@ -1134,7 +1175,7 @@ curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \ - Use ngrok or deploy to staging for local testing: ```bash ngrok http 3000 - # Set PAYLOAD_PUBLIC_SERVER_URL to ngrok URL + # Set NEXT_PUBLIC_SERVER_URL to ngrok URL ``` ### Test Provider Payment Not Processing diff --git a/package.json b/package.json index cc2879c..a4d2798 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-billing", - "version": "0.1.21", + "version": "0.1.22", "description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing", "license": "MIT", "type": "module", diff --git a/src/collections/payments.ts b/src/collections/payments.ts index 8ed128d..0f30aec 100644 --- a/src/collections/payments.ts +++ b/src/collections/payments.ts @@ -86,6 +86,13 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col readOnly: true, }, }, + { + name: 'redirectUrl', + type: 'text', + admin: { + description: 'URL to redirect user after payment completion', + }, + }, { name: 'invoice', type: 'relationship', diff --git a/src/plugin/types/payments.ts b/src/plugin/types/payments.ts index ada717f..268191d 100644 --- a/src/plugin/types/payments.ts +++ b/src/plugin/types/payments.ts @@ -26,6 +26,10 @@ export interface Payment { * Checkout URL where user can complete payment (if applicable) */ checkoutUrl?: string | null; + /** + * URL to redirect user after payment completion + */ + redirectUrl?: string | null; invoice?: (Id | null) | Invoice; /** * Additional metadata for the payment diff --git a/src/providers/mollie.ts b/src/providers/mollie.ts index e4d6b87..9f39f75 100644 --- a/src/providers/mollie.ts +++ b/src/providers/mollie.ts @@ -134,11 +134,24 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & { } // Setup URLs with development defaults + // Only use localhost fallbacks in non-production environments const isProduction = process.env.NODE_ENV === 'production' - const redirectUrl = mollieConfig.redirectUrl || - (!isProduction ? 'https://localhost:3000/payment/success' : undefined) - const webhookUrl = mollieConfig.webhookUrl || - `${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook` + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL + + // Priority: payment.redirectUrl > config.redirectUrl > dev fallback + let redirectUrl = payment.redirectUrl || mollieConfig.redirectUrl + if (!redirectUrl && !isProduction) { + redirectUrl = 'https://localhost:3000/payment/success' + } + + let webhookUrl = mollieConfig.webhookUrl + if (!webhookUrl) { + if (serverUrl) { + webhookUrl = `${serverUrl}/api/payload-billing/mollie/webhook` + } else if (!isProduction) { + webhookUrl = 'https://localhost:3000/api/payload-billing/mollie/webhook' + } + } // Validate URLs for production validateProductionUrl(redirectUrl, 'Redirect') diff --git a/src/providers/stripe.ts b/src/providers/stripe.ts index 2af0ae9..eec2e6f 100644 --- a/src/providers/stripe.ts +++ b/src/providers/stripe.ts @@ -234,6 +234,9 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { const stripe = singleton.get(payload) + // Priority: payment.redirectUrl > config.returnUrl + const returnUrl = payment.redirectUrl || stripeConfig.returnUrl + // Create a payment intent const paymentIntent = await stripe.paymentIntents.create({ amount: payment.amount, // Stripe handles currency conversion internally @@ -250,6 +253,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { automatic_payment_methods: { enabled: true, }, + ...(returnUrl && { return_url: returnUrl }), }) payment.providerId = paymentIntent.id diff --git a/src/providers/test.ts b/src/providers/test.ts index a2b412f..66f9745 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -1,7 +1,7 @@ import type { Payment } from '../plugin/types/payments' import type { PaymentProvider, ProviderData } from '../plugin/types/index' import type { BillingPluginConfig } from '../plugin/config' -import type { Payload } from 'payload' +import type { CollectionSlug, Payload } from 'payload' import { handleWebhookError, logWebhookEvent } from './utils' import { isValidAmount, isValidCurrencyCode } from './currency' import { createContextLogger } from '../utils/logger' @@ -160,6 +160,7 @@ export interface TestPaymentSession { method?: PaymentMethod createdAt: Date status: PaymentOutcome + redirectUrl?: string } // Use the proper BillingPluginConfig type @@ -228,7 +229,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { } const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS - const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000') + const baseUrl = testConfig.baseUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL || 'http://localhost:3000' const uiRoute = testConfig.customUiRoute || '/test-payment' // Test mode warnings will be logged in onInit when payload is available @@ -277,7 +278,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { const paymentsConfig = pluginConfig.collections?.payments const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments') const result = await req.payload.find({ - collection: paymentSlug, + collection: paymentSlug as CollectionSlug, where: { providerId: { equals: paymentId @@ -310,8 +311,11 @@ export const testProvider = (testConfig: TestProviderConfig) => { }) } + // Determine redirect URL: session.redirectUrl > payment.redirectUrl > default + const redirectUrl = session.redirectUrl || (session.payment as Payment)?.redirectUrl || `${baseUrl}/payment/success` + // Generate test payment UI - const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig) + const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig, redirectUrl) return new Response(html, { headers: { 'Content-Type': 'text/html' } }) @@ -370,7 +374,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { const paymentsConfig = pluginConfig.collections?.payments const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments') const result = await req.payload.find({ - collection: paymentSlug, + collection: paymentSlug as CollectionSlug, where: { providerId: { equals: paymentId @@ -592,12 +596,13 @@ export const testProvider = (testConfig: TestProviderConfig) => { // Generate unique test payment ID const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` - // Create test payment session - const session = { + // Create test payment session with redirect URL + const session: TestPaymentSession = { id: testPaymentId, payment: { ...payment }, createdAt: new Date(), - status: 'pending' as PaymentOutcome + status: 'pending' as PaymentOutcome, + redirectUrl: payment.redirectUrl || undefined } testPaymentSessions.set(testPaymentId, session) @@ -716,7 +721,8 @@ function generateTestPaymentUI( scenarios: PaymentScenario[], uiRoute: string, baseUrl: string, - testConfig: TestProviderConfig + testConfig: TestProviderConfig, + redirectUrl: string ): string { const payment = session.payment const testModeIndicators = testConfig.testModeIndicators || {} @@ -1030,9 +1036,9 @@ function generateTestPaymentUI( if (result.status === 'paid') { status.className = 'status success'; - status.textContent = '✅ Payment successful!'; + status.textContent = '✅ Payment successful! Redirecting...'; setTimeout(() => { - window.location.href = '${baseUrl}/success'; + window.location.href = '${redirectUrl}'; }, 2000); } else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') { status.className = 'status error';