5 Commits

Author SHA1 Message Date
1eb9d282b3 fix: use testmode boolean parameter for Mollie payments
Changed from mode: 'test' | 'live' to testmode: boolean as per Mollie
API requirements. The testmode parameter is set to true when the API
key starts with 'test_', false otherwise.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 15:12:09 +01:00
291ce255b4 feat: add automatic mode detection for Mollie payments
Automatically set Mollie payment mode to 'test' or 'live' based on
the API key prefix (test_ or live_). This ensures payments are
created in the correct mode and helps prevent configuration errors.

Changes:
- Add getMollieMode() helper to detect mode from API key
- Include mode parameter in payment creation
- Use type assertion for Mollie client compatibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 14:45:04 +01:00
2904d30a5c fix: improve error logging with detailed messages and stack traces
Previously, error objects were passed directly to the logger without
proper serialization, resulting in empty error messages like "Error:"
with no details. This made debugging production issues impossible.

Changes:
- Extract error message and stack trace before logging
- Format errors consistently across all providers
- Add stack trace logging for better debugging
- Update test provider error handling

This fixes the issue where webhook and payment update errors showed
no useful information in production logs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 14:39:52 +01:00
bf6f546371 fix: handle missing toPlainObject in Mollie client responses
Add fallback for Mollie API responses that may not have toPlainObject method
depending on client version or response type.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:16:45 +01:00
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
8 changed files with 140 additions and 34 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.21", "version": "0.1.26",
"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

@@ -17,6 +17,13 @@ import { createContextLogger } from '../utils/logger'
const symbol = Symbol.for('@xtr-dev/payload-billing/mollie') const symbol = Symbol.for('@xtr-dev/payload-billing/mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0] export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
/**
* Determine if testmode should be enabled based on API key prefix
*/
function isTestMode(apiKey: string): boolean {
return apiKey.startsWith('test_')
}
/** /**
* Type-safe mapping of Mollie payment status to internal status * Type-safe mapping of Mollie payment status to internal status
*/ */
@@ -85,11 +92,15 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
const status = mapMollieStatusToPaymentStatus(molliePayment.status) const status = mapMollieStatusToPaymentStatus(molliePayment.status)
// Update the payment status and provider data // Update the payment status and provider data
// Use toPlainObject if available, otherwise spread the object
const providerData = typeof molliePayment.toPlainObject === 'function'
? molliePayment.toPlainObject()
: { ...molliePayment }
const updateSuccess = await updatePaymentStatus( const updateSuccess = await updatePaymentStatus(
payload, payload,
payment.id, payment.id,
status, status,
molliePayment.toPlainObject(), providerData,
pluginConfig pluginConfig
) )
@@ -134,16 +145,32 @@ 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')
validateProductionUrl(webhookUrl, 'Webhook') validateProductionUrl(webhookUrl, 'Webhook')
// Determine testmode from API key (test_ prefix = true)
const testmode = isTestMode(mollieConfig.apiKey)
const molliePayment = await singleton.get(payload).payments.create({ const molliePayment = await singleton.get(payload).payments.create({
amount: { amount: {
value: formatAmountForProvider(payment.amount, payment.currency), value: formatAmountForProvider(payment.amount, payment.currency),
@@ -152,9 +179,13 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
description: payment.description || '', description: payment.description || '',
redirectUrl, redirectUrl,
webhookUrl, webhookUrl,
}); testmode,
} as any);
payment.providerId = molliePayment.id payment.providerId = molliePayment.id
payment.providerData = molliePayment.toPlainObject() // Use toPlainObject if available, otherwise spread the object (for compatibility with different Mollie client versions)
payment.providerData = typeof molliePayment.toPlainObject === 'function'
? molliePayment.toPlainObject()
: { ...molliePayment }
payment.checkoutUrl = molliePayment._links?.checkout?.href || null payment.checkoutUrl = molliePayment._links?.checkout?.href || null
return payment return payment
}, },

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
@@ -277,7 +278,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
const paymentsConfig = pluginConfig.collections?.payments const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments') const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({ const result = await req.payload.find({
collection: paymentSlug, collection: paymentSlug as CollectionSlug,
where: { where: {
providerId: { providerId: {
equals: paymentId 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 // 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' }
}) })
@@ -370,7 +374,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
const paymentsConfig = pluginConfig.collections?.payments const paymentsConfig = pluginConfig.collections?.payments
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments') const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
const result = await req.payload.find({ const result = await req.payload.find({
collection: paymentSlug, collection: paymentSlug as CollectionSlug,
where: { where: {
providerId: { providerId: {
equals: paymentId equals: paymentId
@@ -420,7 +424,8 @@ export const testProvider = (testConfig: TestProviderConfig) => {
setTimeout(() => { setTimeout(() => {
processTestPayment(payload, session, pluginConfig).catch(async (error) => { processTestPayment(payload, session, pluginConfig).catch(async (error) => {
const logger = createContextLogger(payload, 'Test Provider') const logger = createContextLogger(payload, 'Test Provider')
logger.error('Failed to process payment:', error) const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`Failed to process payment: ${errorMessage}`)
// Ensure session status is updated consistently // Ensure session status is updated consistently
session.status = 'failed' session.status = 'failed'
@@ -592,12 +597,13 @@ 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)
@@ -716,7 +722,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 || {}
@@ -1030,9 +1037,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';

View File

@@ -16,7 +16,7 @@ export const webhookResponses = {
// Log error internally but don't expose details // Log error internally but don't expose details
if (payload) { if (payload) {
const logger = createContextLogger(payload, 'Webhook') const logger = createContextLogger(payload, 'Webhook')
logger.error('Error:', message) logger.error(`Error: ${message}`)
} else { } else {
console.error('[Webhook] Error:', message) console.error('[Webhook] Error:', message)
} }
@@ -126,7 +126,12 @@ export async function updatePaymentStatus(
} }
} catch (error) { } catch (error) {
const logger = createContextLogger(payload, 'Payment Update') const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Failed to update payment ${paymentId}:`, error) const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : undefined
logger.error(`Failed to update payment ${paymentId}: ${errorMessage}`)
if (errorStack) {
logger.error(`Stack trace: ${errorStack}`)
}
return false return false
} }
} }
@@ -165,15 +170,22 @@ export function handleWebhookError(
context?: string, context?: string,
payload?: Payload payload?: Payload
): Response { ): Response {
const message = error instanceof Error ? error.message : 'Unknown error' const message = error instanceof Error ? error.message : String(error)
const stack = error instanceof Error ? error.stack : undefined
const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook` const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook`
// Log detailed error internally for debugging // Log detailed error internally for debugging
if (payload) { if (payload) {
const logger = createContextLogger(payload, fullContext) const logger = createContextLogger(payload, fullContext)
logger.error('Error:', error) logger.error(`Error: ${message}`)
if (stack) {
logger.error(`Stack trace: ${stack}`)
}
} else { } else {
console.error(`[${fullContext}] Error:`, error) console.error(`[${fullContext}] Error: ${message}`)
if (stack) {
console.error(`[${fullContext}] Stack trace:`, stack)
}
} }
// Return generic response to avoid information disclosure // Return generic response to avoid information disclosure