9 Commits

Author SHA1 Message Date
f2ab50214b fix: add fallback for databases without transaction support
Some database adapters don't support transactions, causing payment
updates to fail completely. This change adds graceful fallback to
direct updates when transactions are unavailable.

Changes:
- Try to use transactions if supported
- Fall back to direct update if beginTransaction() fails or returns null
- Add debug logging to track which path is used
- Maintain backward compatibility with transaction-supporting databases

This fixes the "Failed to begin transaction" error in production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 15:33:18 +01:00
20030b435c revert: remove testmode parameter from Mollie payment creation
Removed the testmode parameter as it was causing issues. Mollie will
automatically determine test/live mode based on the API key used.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 15:22:37 +01:00
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
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
8 changed files with 298 additions and 69 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.19", "version": "0.1.28",
"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

@@ -85,11 +85,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,11 +138,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')
@@ -154,7 +171,10 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
webhookUrl, webhookUrl,
}); });
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
@@ -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,
@@ -347,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'
@@ -398,10 +476,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 +502,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 +597,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}${uiRoute}/${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 +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 || {}
@@ -915,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)
} }
@@ -60,6 +60,7 @@ export async function updatePaymentStatus(
pluginConfig: BillingPluginConfig pluginConfig: BillingPluginConfig
): Promise<boolean> { ): Promise<boolean> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection) const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const logger = createContextLogger(payload, 'Payment Update')
try { try {
// First, fetch the current payment to get the current version // First, fetch the current payment to get the current version
@@ -69,23 +70,23 @@ export async function updatePaymentStatus(
}) as Payment }) as Payment
if (!currentPayment) { if (!currentPayment) {
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Payment ${paymentId} not found`) logger.error(`Payment ${paymentId} not found`)
return false return false
} }
const currentVersion = currentPayment.version || 1 const currentVersion = currentPayment.version || 1
// Attempt to update with optimistic locking // Try to use transactions if supported by the database adapter
// We'll use a transaction to ensure atomicity let transactionID: string | number | null = null
const transactionID = await payload.db.beginTransaction() try {
transactionID = await payload.db.beginTransaction()
if (!transactionID) { } catch (error) {
const logger = createContextLogger(payload, 'Payment Update') // Transaction support may not be available in all database adapters
logger.error('Failed to begin transaction') logger.debug('Transactions not supported, falling back to direct update')
return false
} }
if (transactionID) {
// Use transactional update with optimistic locking
try { try {
// Re-fetch within transaction to ensure consistency // Re-fetch within transaction to ensure consistency
const paymentInTransaction = await payload.findByID({ const paymentInTransaction = await payload.findByID({
@@ -97,7 +98,6 @@ export async function updatePaymentStatus(
// Check if version still matches // Check if version still matches
if ((paymentInTransaction.version || 1) !== currentVersion) { if ((paymentInTransaction.version || 1) !== currentVersion) {
// Version conflict detected - payment was modified by another process // Version conflict detected - payment was modified by another process
const logger = createContextLogger(payload, 'Payment Update')
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`) logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
await payload.db.rollbackTransaction(transactionID) await payload.db.rollbackTransaction(transactionID)
return false return false
@@ -124,9 +124,33 @@ export async function updatePaymentStatus(
await payload.db.rollbackTransaction(transactionID) await payload.db.rollbackTransaction(transactionID)
throw error throw error
} }
} else {
// Fallback: Direct update without transaction support
// This is less safe but allows payment updates on databases without transaction support
logger.debug('Using direct update without transaction')
await payload.update({
collection: paymentsCollection,
id: toPayloadId(paymentId),
data: {
status,
providerData: {
...providerData,
webhookProcessedAt: new Date().toISOString()
},
version: currentVersion + 1
}
})
return true
}
} catch (error) { } catch (error) {
const logger = createContextLogger(payload, 'Payment Update') const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`Failed to update payment ${paymentId}:`, 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 +189,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