14 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
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
6de405d07f 0.1.18 2025-11-21 15:39:40 +01:00
7c0b42e35d fix: resolve plugin initialization failure in Next.js API routes
Use Symbol.for() instead of Symbol() for plugin singleton storage to ensure
plugin state persists across different module loading contexts (admin panel,
API routes, server components).

This fixes the "Billing plugin not initialized" error that occurred when
calling payload.create() from Next.js API routes, server components, or
server actions.

Changes:
- Plugin singleton now uses Symbol.for('@xtr-dev/payload-billing')
- Provider singletons (stripe, mollie, test) use global symbols
- Enhanced error message with troubleshooting guidance

Fixes #1

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 15:35:12 +01:00
25b340d818 0.1.17 2025-11-18 23:12:32 +01:00
46bec6bd2e feat: add defaultPopulate configuration to payments collection
- Include defaultPopulate fields to simplify API responses
- Ensure key payment details (amount, status, provider, etc.) are preloaded
2025-11-18 23:12:30 +01:00
10 changed files with 316 additions and 74 deletions

View File

@@ -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<string, any> // 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

View File

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

View File

@@ -7,7 +7,8 @@ export const initProviderPayment = async (payload: Payload, payment: Partial<Pay
if (!billing) {
throw new Error(
'Billing plugin not initialized. Make sure the billingPlugin is properly configured in your Payload config and that Payload has finished initializing.'
'Billing plugin not initialized. Make sure the billingPlugin is properly configured in your Payload config and that Payload has finished initializing. ' +
'If you are calling this from a Next.js API route or Server Component, ensure you are using getPayload() with the same config instance used in your Payload configuration.'
)
}

View File

@@ -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',
@@ -144,6 +151,18 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
useAsTitle: 'id',
},
fields,
defaultPopulate: {
id: true,
provider: true,
status: true,
amount: true,
currency: true,
description: true,
checkoutUrl: true,
providerId: true,
metadata: true,
providerData: true,
},
hooks: {
afterChange: [
async ({ doc, operation, req, previousDoc }) => {

View File

@@ -4,7 +4,7 @@ import type { Config, Payload } from 'payload'
import { createSingleton } from './singleton'
import type { PaymentProvider } from '../providers/index'
const singleton = createSingleton(Symbol('billingPlugin'))
const singleton = createSingleton(Symbol.for('@xtr-dev/payload-billing'))
type BillingPlugin = {
config: BillingPluginConfig

View File

@@ -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

View File

@@ -14,7 +14,7 @@ import {
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('mollie')
const symbol = Symbol.for('@xtr-dev/payload-billing/mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
/**
@@ -85,11 +85,15 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
// 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(
payload,
payment.id,
status,
molliePayment.toPlainObject(),
providerData,
pluginConfig
)
@@ -134,11 +138,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')
@@ -154,7 +171,10 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
webhookUrl,
});
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
return payment
},

View File

@@ -14,7 +14,7 @@ import {
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('stripe')
const symbol = Symbol.for('@xtr-dev/payload-billing/stripe')
export interface StripeProviderConfig {
secretKey: string
@@ -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

View File

@@ -1,12 +1,12 @@
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'
const TestModeWarningSymbol = Symbol('TestModeWarning')
const TestModeWarningSymbol = Symbol.for('@xtr-dev/payload-billing/test-mode-warning')
const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis
const setTestModeWarning = () => ((<any>globalThis)[TestModeWarningSymbol] = true)
@@ -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
@@ -242,10 +243,15 @@ export const testProvider = (testConfig: TestProviderConfig) => {
{
path: '/payload-billing/test/payment/:id',
method: 'get',
handler: (req) => {
handler: async (req) => {
// Extract payment ID from URL path
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) {
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) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
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
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' }
})
@@ -322,7 +365,41 @@ export const testProvider = (testConfig: TestProviderConfig) => {
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) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
@@ -347,7 +424,8 @@ export const testProvider = (testConfig: TestProviderConfig) => {
setTimeout(() => {
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
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
session.status = 'failed'
@@ -398,10 +476,15 @@ export const testProvider = (testConfig: TestProviderConfig) => {
{
path: '/payload-billing/test/status/:id',
method: 'get',
handler: (req) => {
handler: async (req) => {
// Extract payment ID from URL path
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) {
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) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), {
status: 404,
@@ -480,19 +597,23 @@ 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)
// Set provider ID and data
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 = {
raw: {
id: testPaymentId,
@@ -601,7 +722,8 @@ function generateTestPaymentUI(
scenarios: PaymentScenario[],
uiRoute: string,
baseUrl: string,
testConfig: TestProviderConfig
testConfig: TestProviderConfig,
redirectUrl: string
): string {
const payment = session.payment
const testModeIndicators = testConfig.testModeIndicators || {}
@@ -915,9 +1037,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';

View File

@@ -16,7 +16,7 @@ export const webhookResponses = {
// Log error internally but don't expose details
if (payload) {
const logger = createContextLogger(payload, 'Webhook')
logger.error('Error:', message)
logger.error(`Error: ${message}`)
} else {
console.error('[Webhook] Error:', message)
}
@@ -60,6 +60,7 @@ export async function updatePaymentStatus(
pluginConfig: BillingPluginConfig
): Promise<boolean> {
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
const logger = createContextLogger(payload, 'Payment Update')
try {
// First, fetch the current payment to get the current version
@@ -69,41 +70,65 @@ export async function updatePaymentStatus(
}) as Payment
if (!currentPayment) {
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Payment ${paymentId} not found`)
return false
}
const currentVersion = currentPayment.version || 1
// Attempt to update with optimistic locking
// We'll use a transaction to ensure atomicity
const transactionID = await payload.db.beginTransaction()
if (!transactionID) {
const logger = createContextLogger(payload, 'Payment Update')
logger.error('Failed to begin transaction')
return false
// Try to use transactions if supported by the database adapter
let transactionID: string | number | null = null
try {
transactionID = await payload.db.beginTransaction()
} catch (error) {
// Transaction support may not be available in all database adapters
logger.debug('Transactions not supported, falling back to direct update')
}
try {
// Re-fetch within transaction to ensure consistency
const paymentInTransaction = await payload.findByID({
collection: paymentsCollection,
id: toPayloadId(paymentId),
req: { transactionID }
}) as Payment
if (transactionID) {
// Use transactional update with optimistic locking
try {
// Re-fetch within transaction to ensure consistency
const paymentInTransaction = await payload.findByID({
collection: paymentsCollection,
id: toPayloadId(paymentId),
req: { transactionID }
}) as Payment
// Check if version still matches
if ((paymentInTransaction.version || 1) !== currentVersion) {
// 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})`)
// Check if version still matches
if ((paymentInTransaction.version || 1) !== currentVersion) {
// Version conflict detected - payment was modified by another process
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
await payload.db.rollbackTransaction(transactionID)
return false
}
// Update with new version
await payload.update({
collection: paymentsCollection,
id: toPayloadId(paymentId),
data: {
status,
providerData: {
...providerData,
webhookProcessedAt: new Date().toISOString()
},
version: currentVersion + 1
},
req: { transactionID }
})
await payload.db.commitTransaction(transactionID)
return true
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
return false
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')
// Update with new version
await payload.update({
collection: paymentsCollection,
id: toPayloadId(paymentId),
@@ -114,19 +139,18 @@ export async function updatePaymentStatus(
webhookProcessedAt: new Date().toISOString()
},
version: currentVersion + 1
},
req: { transactionID }
}
})
await payload.db.commitTransaction(transactionID)
return true
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
throw error
}
} catch (error) {
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
}
}
@@ -165,15 +189,22 @@ export function handleWebhookError(
context?: string,
payload?: Payload
): 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`
// Log detailed error internally for debugging
if (payload) {
const logger = createContextLogger(payload, fullContext)
logger.error('Error:', error)
logger.error(`Error: ${message}`)
if (stack) {
logger.error(`Stack trace: ${stack}`)
}
} 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