4 Commits

Author SHA1 Message Date
7e4ec86e00 feat: add per-payment redirectUrl field
- 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 <noreply@anthropic.com>
2025-12-02 20:57:21 +01:00
79de7910d4 fix: fetch payment sessions from database for persistence
The test provider was using an in-memory Map to store payment sessions,
which caused "Payment session not found" errors in several scenarios:

1. Next.js hot reload clearing the memory
2. Different execution contexts (API routes vs Payload admin)
3. Server restarts losing all sessions

This fix updates all three test provider endpoints (UI, process, status)
to fetch payment data from the database when not found in memory:

- Tries in-memory session first (fast path)
- Falls back to database query by providerId
- Creates and caches session from database payment
- Handles both string and object collection configurations

This makes the built-in test UI work reliably out of the box, without
requiring users to implement custom session management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 17:31:20 +01:00
bb5ba83bc3 fix: use built-in UI when customUiRoute is not specified
In v0.1.19, the fix for customUiRoute made it always use the default
route '/test-payment' even when customUiRoute was not specified. This
caused 404 errors because users were unaware of this default behavior.

The plugin actually provides a built-in test payment UI at
/api/payload-billing/test/payment/:id that works out of the box.

This fix ensures the correct behavior:
- When customUiRoute IS specified: Use the custom route
- When customUiRoute is NOT specified: Use the built-in UI route

This allows the testProvider to work out of the box without requiring
users to implement a custom test payment page, while still supporting
custom implementations when needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 17:15:59 +01:00
79166f7edf fix: respect customUiRoute configuration in test provider
The test provider's customUiRoute parameter was being ignored when
generating checkout URLs. The checkout URL was always using the
hardcoded API endpoint instead of the configured custom UI route.

This fix ensures that when customUiRoute is configured, the generated
checkoutUrl will use the custom route (e.g., /test-payment/:id)
instead of the default API route.

Fixes issue where test provider checkout URLs returned 404 errors
because they pointed to /api/payload-billing/test/payment/:id instead
of the configured custom UI route.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:17:38 +01:00
7 changed files with 221 additions and 31 deletions

View File

@@ -175,13 +175,15 @@ mollieProvider({
```bash ```bash
MOLLIE_API_KEY=test_... MOLLIE_API_KEY=test_...
MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook # Optional if server URL is set
PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com NEXT_PUBLIC_SERVER_URL=https://yourdomain.com # Or PAYLOAD_PUBLIC_SERVER_URL or SERVER_URL
``` ```
**Important Notes:** **Important Notes:**
- Mollie requires HTTPS URLs in production (no localhost) - 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") - Amounts are formatted as decimal strings (e.g., "50.00")
### Test Provider ### Test Provider
@@ -408,6 +410,7 @@ Tracks payment transactions with provider integration.
currency: string // ISO 4217 currency code currency: string // ISO 4217 currency code
description?: string description?: string
checkoutUrl?: string // Checkout URL (if applicable) checkoutUrl?: string // Checkout URL (if applicable)
redirectUrl?: string // URL to redirect user after payment
invoice?: Invoice | string // Linked invoice invoice?: Invoice | string // Linked invoice
metadata?: Record<string, any> // Custom metadata metadata?: Record<string, any> // Custom metadata
providerData?: ProviderData // Raw provider response (read-only) providerData?: ProviderData // Raw provider response (read-only)
@@ -434,6 +437,36 @@ succeeded → partially_refunded → refunded
- Provider's `initPayment()` called on creation - Provider's `initPayment()` called on creation
- Linked invoice updated when status becomes `succeeded` - 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 ### Invoices
Generate and manage invoices with line items and customer information. Generate and manage invoices with line items and customer information.
@@ -864,10 +897,15 @@ const campaignPayments = await payload.find({
### Mollie Webhook Configuration ### Mollie Webhook Configuration
1. **Set webhook URL:** 1. **Set server URL** (webhook URL is auto-generated):
```bash ```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 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 2. **Mollie automatically calls webhook** for payment status updates
@@ -875,12 +913,13 @@ const campaignPayments = await payload.find({
3. **Test locally with ngrok:** 3. **Test locally with ngrok:**
```bash ```bash
ngrok http 3000 ngrok http 3000
# Use ngrok URL as PAYLOAD_PUBLIC_SERVER_URL # Use ngrok URL as NEXT_PUBLIC_SERVER_URL
``` ```
**Important:** **Important:**
- Mollie requires HTTPS URLs (no `http://` or `localhost` in production) - 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 - Mollie validates webhooks by verifying payment ID exists
### Webhook Security ### Webhook Security
@@ -1096,8 +1135,10 @@ echo $STRIPE_WEBHOOK_SECRET
**Mollie:** **Mollie:**
```bash ```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 $PAYLOAD_PUBLIC_SERVER_URL
echo $SERVER_URL
# Check webhook URL is accessible (must be HTTPS in production) # Check webhook URL is accessible (must be HTTPS in production)
curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \ 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: - Use ngrok or deploy to staging for local testing:
```bash ```bash
ngrok http 3000 ngrok http 3000
# Set PAYLOAD_PUBLIC_SERVER_URL to ngrok URL # Set NEXT_PUBLIC_SERVER_URL to ngrok URL
``` ```
### Test Provider Payment Not Processing ### Test Provider Payment Not Processing

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-billing", "name": "@xtr-dev/payload-billing",
"version": "0.1.18", "version": "0.1.22",
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing", "description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -86,6 +86,13 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
readOnly: true, readOnly: true,
}, },
}, },
{
name: 'redirectUrl',
type: 'text',
admin: {
description: 'URL to redirect user after payment completion',
},
},
{ {
name: 'invoice', name: 'invoice',
type: 'relationship', type: 'relationship',

View File

@@ -26,6 +26,10 @@ export interface Payment {
* Checkout URL where user can complete payment (if applicable) * Checkout URL where user can complete payment (if applicable)
*/ */
checkoutUrl?: string | null; checkoutUrl?: string | null;
/**
* URL to redirect user after payment completion
*/
redirectUrl?: string | null;
invoice?: (Id | null) | Invoice; invoice?: (Id | null) | Invoice;
/** /**
* Additional metadata for the payment * Additional metadata for the payment

View File

@@ -134,11 +134,24 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
} }
// Setup URLs with development defaults // Setup URLs with development defaults
// Only use localhost fallbacks in non-production environments
const isProduction = process.env.NODE_ENV === 'production' const isProduction = process.env.NODE_ENV === 'production'
const redirectUrl = mollieConfig.redirectUrl || const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL
(!isProduction ? 'https://localhost:3000/payment/success' : undefined)
const webhookUrl = mollieConfig.webhookUrl || // Priority: payment.redirectUrl > config.redirectUrl > dev fallback
`${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook` 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 // Validate URLs for production
validateProductionUrl(redirectUrl, 'Redirect') validateProductionUrl(redirectUrl, 'Redirect')

View File

@@ -234,6 +234,9 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
const stripe = singleton.get(payload) const stripe = singleton.get(payload)
// Priority: payment.redirectUrl > config.returnUrl
const returnUrl = payment.redirectUrl || stripeConfig.returnUrl
// Create a payment intent // Create a payment intent
const paymentIntent = await stripe.paymentIntents.create({ const paymentIntent = await stripe.paymentIntents.create({
amount: payment.amount, // Stripe handles currency conversion internally amount: payment.amount, // Stripe handles currency conversion internally
@@ -250,6 +253,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
automatic_payment_methods: { automatic_payment_methods: {
enabled: true, enabled: true,
}, },
...(returnUrl && { return_url: returnUrl }),
}) })
payment.providerId = paymentIntent.id payment.providerId = paymentIntent.id

View File

@@ -1,7 +1,7 @@
import type { Payment } from '../plugin/types/payments' import type { Payment } from '../plugin/types/payments'
import type { PaymentProvider, ProviderData } from '../plugin/types/index' import type { PaymentProvider, ProviderData } from '../plugin/types/index'
import type { BillingPluginConfig } from '../plugin/config' import type { BillingPluginConfig } from '../plugin/config'
import type { Payload } from 'payload' import type { CollectionSlug, Payload } from 'payload'
import { handleWebhookError, logWebhookEvent } from './utils' import { handleWebhookError, logWebhookEvent } from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency' import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger' import { createContextLogger } from '../utils/logger'
@@ -160,6 +160,7 @@ export interface TestPaymentSession {
method?: PaymentMethod method?: PaymentMethod
createdAt: Date createdAt: Date
status: PaymentOutcome status: PaymentOutcome
redirectUrl?: string
} }
// Use the proper BillingPluginConfig type // Use the proper BillingPluginConfig type
@@ -228,7 +229,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
} }
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS 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' const uiRoute = testConfig.customUiRoute || '/test-payment'
// Test mode warnings will be logged in onInit when payload is available // Test mode warnings will be logged in onInit when payload is available
@@ -242,10 +243,15 @@ export const testProvider = (testConfig: TestProviderConfig) => {
{ {
path: '/payload-billing/test/payment/:id', path: '/payload-billing/test/payment/:id',
method: 'get', method: 'get',
handler: (req) => { handler: async (req) => {
// Extract payment ID from URL path // Extract payment ID from URL path
const urlParts = req.url?.split('/') || [] const urlParts = req.url?.split('/') || []
const paymentId = urlParts[urlParts.length - 1] let paymentId = urlParts[urlParts.length - 1]
// Remove query parameters if present
if (paymentId?.includes('?')) {
paymentId = paymentId.split('?')[0]
}
if (!paymentId) { if (!paymentId) {
return new Response(JSON.stringify({ error: 'Payment ID required' }), { return new Response(JSON.stringify({ error: 'Payment ID required' }), {
@@ -263,7 +269,41 @@ export const testProvider = (testConfig: TestProviderConfig) => {
}) })
} }
const session = testPaymentSessions.get(paymentId) // Try to get session from memory first (for backward compatibility)
let session = testPaymentSessions.get(paymentId)
// If not in memory, fetch from database
if (!session && req.payload) {
try {
const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({
collection: paymentSlug as CollectionSlug,
where: {
providerId: {
equals: paymentId
}
},
limit: 1
})
if (result.docs && result.docs.length > 0) {
const payment = result.docs[0] as Payment
// Create session from database payment
session = {
id: paymentId,
payment: payment,
createdAt: new Date(payment.createdAt || Date.now()),
status: 'pending' as PaymentOutcome
}
// Store in memory for future requests
testPaymentSessions.set(paymentId, session)
}
} catch (error) {
console.error('Error fetching payment from database:', error)
}
}
if (!session) { if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), { return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404, status: 404,
@@ -271,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 // 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, { return new Response(html, {
headers: { 'Content-Type': 'text/html' } headers: { 'Content-Type': 'text/html' }
}) })
@@ -322,7 +365,41 @@ export const testProvider = (testConfig: TestProviderConfig) => {
const { paymentId, scenarioId, method } = validation.data! const { paymentId, scenarioId, method } = validation.data!
const session = testPaymentSessions.get(paymentId) // Try to get session from memory first
let session = testPaymentSessions.get(paymentId)
// If not in memory, fetch from database
if (!session && req.payload) {
try {
const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({
collection: paymentSlug as CollectionSlug,
where: {
providerId: {
equals: paymentId
}
},
limit: 1
})
if (result.docs && result.docs.length > 0) {
const payment = result.docs[0] as Payment
// Create session from database payment
session = {
id: paymentId,
payment: payment,
createdAt: new Date(payment.createdAt || Date.now()),
status: 'pending' as PaymentOutcome
}
// Store in memory for future requests
testPaymentSessions.set(paymentId, session)
}
} catch (error) {
console.error('Error fetching payment from database:', error)
}
}
if (!session) { if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), { return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404, status: 404,
@@ -398,10 +475,15 @@ export const testProvider = (testConfig: TestProviderConfig) => {
{ {
path: '/payload-billing/test/status/:id', path: '/payload-billing/test/status/:id',
method: 'get', method: 'get',
handler: (req) => { handler: async (req) => {
// Extract payment ID from URL path // Extract payment ID from URL path
const urlParts = req.url?.split('/') || [] const urlParts = req.url?.split('/') || []
const paymentId = urlParts[urlParts.length - 1] let paymentId = urlParts[urlParts.length - 1]
// Remove query parameters if present
if (paymentId?.includes('?')) {
paymentId = paymentId.split('?')[0]
}
if (!paymentId) { if (!paymentId) {
return new Response(JSON.stringify({ error: 'Payment ID required' }), { return new Response(JSON.stringify({ error: 'Payment ID required' }), {
@@ -419,7 +501,41 @@ export const testProvider = (testConfig: TestProviderConfig) => {
}) })
} }
const session = testPaymentSessions.get(paymentId) // Try to get session from memory first
let session = testPaymentSessions.get(paymentId)
// If not in memory, fetch from database
if (!session && req.payload) {
try {
const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({
collection: paymentSlug,
where: {
providerId: {
equals: paymentId
}
},
limit: 1
})
if (result.docs && result.docs.length > 0) {
const payment = result.docs[0] as Payment
// Create session from database payment
session = {
id: paymentId,
payment: payment,
createdAt: new Date(payment.createdAt || Date.now()),
status: 'pending' as PaymentOutcome
}
// Store in memory for future requests
testPaymentSessions.set(paymentId, session)
}
} catch (error) {
console.error('Error fetching payment from database:', error)
}
}
if (!session) { if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), { return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404, status: 404,
@@ -480,19 +596,23 @@ export const testProvider = (testConfig: TestProviderConfig) => {
// Generate unique test payment ID // Generate unique test payment ID
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// Create test payment session // Create test payment session with redirect URL
const session = { const session: TestPaymentSession = {
id: testPaymentId, id: testPaymentId,
payment: { ...payment }, payment: { ...payment },
createdAt: new Date(), createdAt: new Date(),
status: 'pending' as PaymentOutcome status: 'pending' as PaymentOutcome,
redirectUrl: payment.redirectUrl || undefined
} }
testPaymentSessions.set(testPaymentId, session) testPaymentSessions.set(testPaymentId, session)
// Set provider ID and data // Set provider ID and data
payment.providerId = testPaymentId payment.providerId = testPaymentId
const paymentUrl = `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}` // Use custom UI route if specified, otherwise use built-in UI endpoint
const paymentUrl = testConfig.customUiRoute
? `${baseUrl}${testConfig.customUiRoute}/${testPaymentId}`
: `${baseUrl}/api/payload-billing/test/payment/${testPaymentId}`
const providerData: ProviderData = { const providerData: ProviderData = {
raw: { raw: {
id: testPaymentId, id: testPaymentId,
@@ -601,7 +721,8 @@ function generateTestPaymentUI(
scenarios: PaymentScenario[], scenarios: PaymentScenario[],
uiRoute: string, uiRoute: string,
baseUrl: string, baseUrl: string,
testConfig: TestProviderConfig testConfig: TestProviderConfig,
redirectUrl: string
): string { ): string {
const payment = session.payment const payment = session.payment
const testModeIndicators = testConfig.testModeIndicators || {} const testModeIndicators = testConfig.testModeIndicators || {}
@@ -915,9 +1036,9 @@ function generateTestPaymentUI(
if (result.status === 'paid') { if (result.status === 'paid') {
status.className = 'status success'; status.className = 'status success';
status.textContent = '✅ Payment successful!'; status.textContent = '✅ Payment successful! Redirecting...';
setTimeout(() => { setTimeout(() => {
window.location.href = '${baseUrl}/success'; window.location.href = '${redirectUrl}';
}, 2000); }, 2000);
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') { } else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
status.className = 'status error'; status.className = 'status error';