7 Commits

Author SHA1 Message Date
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
7 changed files with 142 additions and 14 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-billing", "name": "@xtr-dev/payload-billing",
"version": "0.1.16", "version": "0.1.21",
"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

@@ -7,7 +7,8 @@ export const initProviderPayment = async (payload: Payload, payment: Partial<Pay
if (!billing) { if (!billing) {
throw new Error( 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

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

View File

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

View File

@@ -14,7 +14,7 @@ import {
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency' import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger' 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] export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
/** /**

View File

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

View File

@@ -6,7 +6,7 @@ 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'
const TestModeWarningSymbol = Symbol('TestModeWarning') const TestModeWarningSymbol = Symbol.for('@xtr-dev/payload-billing/test-mode-warning')
const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis
const setTestModeWarning = () => ((<any>globalThis)[TestModeWarningSymbol] = true) const setTestModeWarning = () => ((<any>globalThis)[TestModeWarningSymbol] = true)
@@ -242,10 +242,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 +268,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,
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,
@@ -322,7 +361,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,
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 +471,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 +497,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,
@@ -492,7 +604,10 @@ export const testProvider = (testConfig: TestProviderConfig) => {
// 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,